// Copyright 2026 Moko Consulting // 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 }