feat: issue status presets and cross-org migration (#507) #709

Merged
jmiller merged 4 commits from feature/status-presets into dev 2026-06-28 08:49:39 +00:00
9 changed files with 407 additions and 0 deletions
+2
View File
@@ -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)
+205
View File
@@ -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
// ──────────────────────────────────────────────────────────────────────
+18
View File
@@ -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 {
+5
View File
@@ -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.",
+5
View File
@@ -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())
+123
View File
@@ -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)
}
+26
View File
@@ -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"))
+1
View File
@@ -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)
})
@@ -62,6 +62,28 @@
<div class="divider"></div>
<h5>{{ctx.Locale.Tr "org.settings.issue_status_presets"}}</h5>
<p class="text grey">{{ctx.Locale.Tr "org.settings.issue_status_presets_desc"}}</p>
<form class="ui form" method="post" action="{{.OrgLink}}/settings/issue-statuses/apply-preset">
{{.CsrfTokenHtml}}
<div class="inline fields">
<div class="field">
<select name="preset" class="ui dropdown">
{{range .StatusPresets}}
<option value="{{.Name}}">{{.Description}}</option>
{{end}}
</select>
</div>
<div class="field">
<button class="ui primary button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "org.settings.issue_status_preset_confirm"}}')">
{{svg "octicon-checklist" 14}} {{ctx.Locale.Tr "org.settings.issue_status_preset_apply"}}
</button>
</div>
</div>
</form>
<div class="divider"></div>
<h5>{{ctx.Locale.Tr "org.settings.issue_status_add"}}</h5>
<form class="ui form" method="post" action="{{.OrgLink}}/settings/issue-statuses">
{{.CsrfTokenHtml}}