Files
jmiller df9305758f
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Generic: Project CI / Lint & Validate (pull_request) Successful in 46s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m16s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
feat: add issue status presets and cross-org migration (#507)
4 built-in presets: default, software-development, support-tickets,
bug-tracking. API endpoints to list presets, apply to org, and copy
statuses between orgs. Web UI dropdown on org settings page.

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:05:14 -05:00

364 lines
14 KiB
Go

// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package issues
import (
"context"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(IssueStatusDef))
}
// IssueStatusDef defines a custom issue status at the org level.
type IssueStatusDef struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'org_id'"`
Name string `xorm:"NOT NULL"`
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
Description string `xorm:"TEXT"`
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
}
func (IssueStatusDef) TableName() string {
return "issue_status_def"
}
// ──────────────────────────────────────────────────────────────────────
// Presets
// ──────────────────────────────────────────────────────────────────────
// StatusPresetEntry defines a single status in a preset template.
type StatusPresetEntry struct {
Name string
Color string
Description string
ClosesIssue bool
IsRequired bool
}
// StatusPreset defines a named collection of status definitions.
type StatusPreset struct {
Name string
Description string
Statuses []StatusPresetEntry
}
// StatusPresets is the registry of built-in status presets.
var StatusPresets = map[string]*StatusPreset{
"default": {
Name: "default",
Description: "General-purpose workflow (default seed)",
Statuses: []StatusPresetEntry{
{Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true},
{Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done"},
{Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input"},
{Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review"},
{Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true},
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true},
},
},
"software-development": {
Name: "software-development",
Description: "Software development lifecycle",
Statuses: []StatusPresetEntry{
{Name: "Open", Color: "#2563eb", Description: "New or active issue", IsRequired: true},
{Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on this"},
{Name: "In Review", Color: "#0891b2", Description: "Pull request submitted, awaiting review"},
{Name: "Testing", Color: "#d97706", Description: "Being tested or in QA"},
{Name: "Closed", Color: "#16a34a", Description: "Completed, merged, and deployed", ClosesIssue: true, IsRequired: true},
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true},
},
},
"support-tickets": {
Name: "support-tickets",
Description: "Customer support ticket workflow",
Statuses: []StatusPresetEntry{
{Name: "New", Color: "#2563eb", Description: "Ticket received, not yet triaged", IsRequired: true},
{Name: "Assigned", Color: "#7c3aed", Description: "Assigned to a support agent"},
{Name: "Waiting for Customer", Color: "#f59e0b", Description: "Awaiting customer response"},
{Name: "In Progress", Color: "#0891b2", Description: "Agent is actively working on this"},
{Name: "Resolved", Color: "#16a34a", Description: "Issue resolved, awaiting confirmation", ClosesIssue: true},
{Name: "Closed", Color: "#059669", Description: "Confirmed resolved", ClosesIssue: true, IsRequired: true},
},
},
"bug-tracking": {
Name: "bug-tracking",
Description: "Bug lifecycle tracking",
Statuses: []StatusPresetEntry{
{Name: "New", Color: "#2563eb", Description: "Bug reported, not yet triaged", IsRequired: true},
{Name: "Confirmed", Color: "#dc2626", Description: "Bug confirmed and reproducible"},
{Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on a fix"},
{Name: "Fixed", Color: "#0891b2", Description: "Fix implemented, awaiting verification"},
{Name: "Verified", Color: "#16a34a", Description: "Fix verified by QA"},
{Name: "Closed", Color: "#059669", Description: "Bug resolved and closed", ClosesIssue: true, IsRequired: true},
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to fix", ClosesIssue: true},
},
},
}
// StatusPresetNames returns the list of available preset names in display order.
func StatusPresetNames() []string {
return []string{"default", "software-development", "support-tickets", "bug-tracking"}
}
// ApplyStatusPreset replaces all non-required statuses for an org with a preset.
// Required statuses (Open/Closed) are preserved if they already exist; the preset's
// required entries are created if missing. Non-required statuses are soft-deleted
// (is_active=false) and the preset's non-required entries are inserted.
func ApplyStatusPreset(ctx context.Context, orgID int64, presetName string) error {
preset, ok := StatusPresets[presetName]
if !ok {
return db.ErrNotExist{Resource: "StatusPreset", ID: 0}
}
existing, err := GetAllIssueStatusDefsByOrg(ctx, orgID)
if err != nil {
return err
}
// Build lookup of existing statuses by name
existingByName := make(map[string]*IssueStatusDef, len(existing))
for _, d := range existing {
existingByName[d.Name] = d
}
// Deactivate all non-required existing statuses
for _, d := range existing {
if d.IsRequired {
continue
}
if d.IsActive {
d.IsActive = false
if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil {
return err
}
}
}
// Apply preset entries
for i, entry := range preset.Statuses {
if ex, found := existingByName[entry.Name]; found {
// Update existing status to match preset
ex.Color = entry.Color
ex.Description = entry.Description
ex.ClosesIssue = entry.ClosesIssue
ex.SortOrder = i
ex.IsActive = true
if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil {
return err
}
} else {
// Create new status
def := &IssueStatusDef{
OrgID: orgID,
Name: entry.Name,
Color: entry.Color,
Description: entry.Description,
ClosesIssue: entry.ClosesIssue,
IsRequired: entry.IsRequired,
SortOrder: i,
IsActive: true,
}
if _, err := db.GetEngine(ctx).Insert(def); err != nil {
return err
}
}
}
return nil
}
// CopyStatusesFromOrg copies all active status definitions from srcOrgID to dstOrgID.
// Existing non-required statuses in dstOrgID are deactivated first.
func CopyStatusesFromOrg(ctx context.Context, srcOrgID, dstOrgID int64) error {
srcDefs, err := GetIssueStatusDefsByOrg(ctx, srcOrgID)
if err != nil {
return err
}
existing, err := GetAllIssueStatusDefsByOrg(ctx, dstOrgID)
if err != nil {
return err
}
existingByName := make(map[string]*IssueStatusDef, len(existing))
for _, d := range existing {
existingByName[d.Name] = d
}
// Deactivate non-required existing statuses
for _, d := range existing {
if d.IsRequired {
continue
}
if d.IsActive {
d.IsActive = false
if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil {
return err
}
}
}
// Copy source statuses
for _, src := range srcDefs {
if ex, found := existingByName[src.Name]; found {
ex.Color = src.Color
ex.Description = src.Description
ex.ClosesIssue = src.ClosesIssue
ex.SortOrder = src.SortOrder
ex.IsActive = true
if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil {
return err
}
} else {
def := &IssueStatusDef{
OrgID: dstOrgID,
Name: src.Name,
Color: src.Color,
Description: src.Description,
ClosesIssue: src.ClosesIssue,
IsRequired: src.IsRequired,
SortOrder: src.SortOrder,
IsActive: true,
}
if _, err := db.GetEngine(ctx).Insert(def); err != nil {
return err
}
}
}
return nil
}
// ──────────────────────────────────────────────────────────────────────
// Queries
// ──────────────────────────────────────────────────────────────────────
// GetIssueStatusDefsByOrg returns active status definitions for an org.
// If none exist, seeds the org with default statuses automatically.
func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) {
defs := make([]*IssueStatusDef, 0, 10)
if err := db.GetEngine(ctx).
Where("org_id = ? AND is_active = ?", orgID, true).
OrderBy("sort_order ASC, id ASC").
Find(&defs); err != nil {
return nil, err
}
if len(defs) == 0 && orgID > 0 {
if err := seedDefaultIssueStatuses(ctx, orgID); err != nil {
return defs, nil // non-fatal
}
return GetIssueStatusDefsByOrg(ctx, orgID)
}
return defs, nil
}
// seedDefaultIssueStatuses creates the standard status presets for an org.
// Open and Closed are required (is_required=true) and cannot be deleted.
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
defaults := []*IssueStatusDef{
{OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
{OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
{OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
{OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
}
for _, d := range defaults {
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
return err
}
}
return nil
}
// GetAllIssueStatusDefsByOrg returns all status definitions (including inactive).
func GetAllIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) {
defs := make([]*IssueStatusDef, 0, 10)
return defs, db.GetEngine(ctx).
Where("org_id = ?", orgID).
OrderBy("sort_order ASC, id ASC").
Find(&defs)
}
// GetIssueStatusDefByID returns a single status definition.
func GetIssueStatusDefByID(ctx context.Context, id int64) (*IssueStatusDef, error) {
def := new(IssueStatusDef)
has, err := db.GetEngine(ctx).ID(id).Get(def)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "IssueStatusDef", ID: id}
}
return def, nil
}
// ──────────────────────────────────────────────────────────────────────
// CRUD
// ──────────────────────────────────────────────────────────────────────
// CreateIssueStatusDef creates a new status definition.
func CreateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
_, err := db.GetEngine(ctx).Insert(def)
return err
}
// UpdateIssueStatusDef updates a status definition.
func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
_, err := db.GetEngine(ctx).ID(def.ID).AllCols().Update(def)
return err
}
// ErrStatusRequired is returned when trying to delete a required status.
type ErrStatusRequired struct {
ID int64
Name string
}
func (e ErrStatusRequired) Error() string {
return "status is required and cannot be deleted"
}
// IsErrStatusRequired checks if an error is ErrStatusRequired.
func IsErrStatusRequired(err error) bool {
_, ok := err.(ErrStatusRequired)
return ok
}
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
// Returns ErrStatusRequired if the status is marked as required.
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
def, err := GetIssueStatusDefByID(ctx, id)
if err != nil {
return err
}
if def.IsRequired {
return ErrStatusRequired{ID: def.ID, Name: def.Name}
}
// Clear status_id on all issues that reference this definition
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
return err
}
_, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
return err
}
// ──────────────────────────────────────────────────────────────────────
// Issue status helpers
// ──────────────────────────────────────────────────────────────────────
// SetIssueStatusID updates the status_id on an issue.
func SetIssueStatusID(ctx context.Context, issueID, statusID int64) error {
_, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = ? WHERE id = ?", statusID, issueID)
return err
}