diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e70c9cb1..09803a0167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### Added +- Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507) +- Cross-org status migration: copy status definitions from one org to another via API (#507) - Auto-create default teams on org creation: Developers (write), Reviewers (read), CI/CD (actions+packages) (#513) - Branch protection delete allowlist: configurable per-user/team/deploy-key allowlist for deleting protected branches (#696) - Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693) diff --git a/models/issues/issue_status.go b/models/issues/issue_status.go index 57318f9fd2..a3b799d27f 100644 --- a/models/issues/issue_status.go +++ b/models/issues/issue_status.go @@ -33,6 +33,211 @@ 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 // ────────────────────────────────────────────────────────────────────── diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 002b46b8e1..1a62b8a767 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -169,6 +169,24 @@ type IssueStatusDef struct { SortOrder int `json:"sort_order"` } +// StatusPresetEntry represents a single status in a preset template. +// swagger:model +type StatusPresetEntry struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` + ClosesIssue bool `json:"closes_issue"` + IsRequired bool `json:"is_required"` +} + +// StatusPreset represents a named status preset template. +// swagger:model +type StatusPreset struct { + Name string `json:"name"` + Description string `json:"description"` + Statuses []*StatusPresetEntry `json:"statuses"` +} + // IssuePriorityDef represents an org-level issue priority definition // swagger:model type IssuePriorityDef struct { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f38506433a..ea52615095 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3009,6 +3009,11 @@ "org.settings.issue_status_created": "Issue status created.", "org.settings.issue_status_updated": "Issue status updated.", "org.settings.issue_status_deleted": "Issue status deleted.", + "org.settings.issue_status_presets": "Status Presets", + "org.settings.issue_status_presets_desc": "Apply a preset template to replace your current statuses. Required statuses (Open/Closed) are preserved; others are deactivated and replaced.", + "org.settings.issue_status_preset_apply": "Apply Preset", + "org.settings.issue_status_preset_confirm": "This will deactivate your current custom statuses and replace them with the selected preset. Required statuses are preserved. Continue?", + "org.settings.issue_status_preset_applied": "Status preset applied successfully.", "org.settings.issue_priorities": "Issue Priorities", "org.settings.issue_priorities_desc": "Define priority levels for all repositories in this organization. Priorities appear in the issue sidebar.", "org.settings.issue_priorities_empty": "No custom issue priorities defined yet.", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 20139b15c7..5967370c6b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1790,6 +1790,11 @@ func Routes() *web.Router { m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField) }) m.Get("/issue-statuses", org.ListIssueStatuses) + m.Group("/issue-statuses", func() { + m.Get("/presets", org.ListIssueStatusPresets) + m.Post("/presets/{preset}", reqToken(), reqOrgOwnership(), org.ApplyIssueStatusPreset) + m.Post("/copy/{source_org}", reqToken(), reqOrgOwnership(), org.CopyIssueStatusesFromOrg) + }) m.Get("/issue-priorities", org.ListIssuePriorities) m.Get("/issue-types", org.ListIssueTypes) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) diff --git a/routers/api/v1/org/issue_metadata.go b/routers/api/v1/org/issue_metadata.go index 5566b457a0..a6aebc72e8 100644 --- a/routers/api/v1/org/issue_metadata.go +++ b/routers/api/v1/org/issue_metadata.go @@ -6,7 +6,9 @@ package org import ( "net/http" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" + org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization" api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" ) @@ -162,3 +164,124 @@ func ListIssueTypes(ctx *context.APIContext) { } ctx.JSON(http.StatusOK, result) } + +// ListIssueStatusPresets returns the available status preset templates. +func ListIssueStatusPresets(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/issue-statuses/presets organization orgListIssueStatusPresets + // --- + // summary: List available issue status presets + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // description: "StatusPresetList" + + result := make([]*api.StatusPreset, 0, len(issues_model.StatusPresetNames())) + for _, name := range issues_model.StatusPresetNames() { + preset := issues_model.StatusPresets[name] + statuses := make([]*api.StatusPresetEntry, 0, len(preset.Statuses)) + for _, s := range preset.Statuses { + statuses = append(statuses, &api.StatusPresetEntry{ + Name: s.Name, + Color: s.Color, + Description: s.Description, + ClosesIssue: s.ClosesIssue, + IsRequired: s.IsRequired, + }) + } + result = append(result, &api.StatusPreset{ + Name: preset.Name, + Description: preset.Description, + Statuses: statuses, + }) + } + ctx.JSON(http.StatusOK, result) +} + +// ApplyIssueStatusPreset applies a status preset to an organization. +func ApplyIssueStatusPreset(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/issue-statuses/presets/{preset} organization orgApplyIssueStatusPreset + // --- + // summary: Apply a status preset to an organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: preset + // in: path + // description: preset name + // type: string + // required: true + // responses: + // "204": + // description: "StatusPresetApplied" + // "404": + // "$ref": "#/responses/notFound" + + presetName := ctx.PathParam("preset") + if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil { + if db.IsErrNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + ctx.Status(http.StatusNoContent) +} + +// CopyIssueStatusesFromOrg copies status definitions from another organization. +func CopyIssueStatusesFromOrg(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/issue-statuses/copy/{source_org} organization orgCopyIssueStatuses + // --- + // summary: Copy issue statuses from another organization + // parameters: + // - name: org + // in: path + // description: target organization name + // type: string + // required: true + // - name: source_org + // in: path + // description: source organization name to copy from + // type: string + // required: true + // responses: + // "204": + // description: "StatusesCopied" + // "404": + // "$ref": "#/responses/notFound" + + sourceOrgName := ctx.PathParam("source_org") + sourceOrg, err := org_model.GetOrgByName(ctx, sourceOrgName) + if err != nil { + ctx.APIErrorNotFound() + return + } + + if sourceOrg.Visibility != api.VisibleTypePublic && !ctx.Doer.IsAdmin { + isMember, err := org_model.IsOrganizationMember(ctx, sourceOrg.ID, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !isMember { + ctx.APIErrorNotFound() + return + } + } + + if err := issues_model.CopyStatusesFromOrg(ctx, sourceOrg.ID, ctx.Org.Organization.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/org/issue_statuses.go b/routers/web/org/issue_statuses.go index 203d56b0a0..578689bedb 100644 --- a/routers/web/org/issue_statuses.go +++ b/routers/web/org/issue_statuses.go @@ -27,9 +27,35 @@ func SettingsIssueStatuses(ctx *context.Context) { } ctx.Data["IssueStatuses"] = defs + // Load preset names for the preset selector + presetNames := issues_model.StatusPresetNames() + type presetInfo struct { + Name string + Description string + } + presets := make([]presetInfo, 0, len(presetNames)) + for _, name := range presetNames { + p := issues_model.StatusPresets[name] + presets = append(presets, presetInfo{Name: p.Name, Description: p.Description}) + } + ctx.Data["StatusPresets"] = presets + ctx.HTML(http.StatusOK, tplOrgIssueStatuses) } +// SettingsIssueStatusesApplyPresetPost applies a status preset to the org. +func SettingsIssueStatusesApplyPresetPost(ctx *context.Context) { + presetName := ctx.FormString("preset") + if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil { + ctx.Flash.Error("Unknown preset: " + presetName) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_status_preset_applied")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") +} + // SettingsIssueStatusesCreatePost creates a new org-level issue status. func SettingsIssueStatusesCreatePost(ctx *context.Context) { sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order")) diff --git a/routers/web/web.go b/routers/web/web.go index 72660737f4..8f9840a22f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1091,6 +1091,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/issue-statuses", func() { m.Get("", org.SettingsIssueStatuses) m.Post("", org.SettingsIssueStatusesCreatePost) + m.Post("/apply-preset", org.SettingsIssueStatusesApplyPresetPost) m.Post("/{id}/edit", org.SettingsIssueStatusesEditPost) m.Post("/{id}/delete", org.SettingsIssueStatusesDeletePost) }) diff --git a/templates/org/settings/issue_statuses.tmpl b/templates/org/settings/issue_statuses.tmpl index 9d10ae8b64..bcb597807f 100644 --- a/templates/org/settings/issue_statuses.tmpl +++ b/templates/org/settings/issue_statuses.tmpl @@ -62,6 +62,28 @@
+{{ctx.Locale.Tr "org.settings.issue_status_presets_desc"}}
+ + + +