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
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
364 lines
14 KiB
Go
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
|
|
}
|