From c568e199ed803219b659c06cb97a028db6ca802c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 08:24:44 -0500 Subject: [PATCH] feat(issues): custom status definitions with automated actions (#502) Add org-level custom issue status definitions that appear in the issue sidebar. Each status has a name, color, description, and an optional "closes issue" flag that automatically closes/reopens the issue when the status is selected. Includes: - IssueStatusDef model with CRUD operations - Migration v346 adding issue_status_def table + status_id on issues - Org settings UI for managing statuses - Issue sidebar dropdown for selecting status - Auto close/reopen when status has closes_issue flag Co-Authored-By: Claude Opus 4.6 (1M context) --- models/issues/issue.go | 2 + models/issues/issue_status.go | 104 ++++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_27/v346.go | 34 ++++++ options/locale/locale_en-US.json | 16 +++ routers/web/org/issue_statuses.go | 112 ++++++++++++++++++ routers/web/repo/issue_custom_status.go | 59 +++++++++ routers/web/repo/issue_view.go | 8 ++ routers/web/web.go | 7 ++ templates/org/settings/issue_statuses.tmpl | 93 +++++++++++++++ templates/org/settings/navbar.tmpl | 3 + .../repo/issue/sidebar/issue_status.tmpl | 33 ++++++ .../repo/issue/view_content/sidebar.tmpl | 2 + 13 files changed, 474 insertions(+) create mode 100644 models/issues/issue_status.go create mode 100644 models/migrations/v1_27/v346.go create mode 100644 routers/web/org/issue_statuses.go create mode 100644 routers/web/repo/issue_custom_status.go create mode 100644 templates/org/settings/issue_statuses.tmpl create mode 100644 templates/repo/issue/sidebar/issue_status.tmpl diff --git a/models/issues/issue.go b/models/issues/issue.go index 817f899dab..1992008b2f 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -76,6 +76,8 @@ type Issue struct { Assignee *user_model.User `xorm:"-"` isAssigneeLoaded bool `xorm:"-"` IsClosed bool `xorm:"INDEX"` + StatusID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'status_id'"` + Status *IssueStatusDef `xorm:"-"` IsRead bool `xorm:"-"` IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not. PullRequest *PullRequest `xorm:"-"` diff --git a/models/issues/issue_status.go b/models/issues/issue_status.go new file mode 100644 index 0000000000..4f35cbd3f7 --- /dev/null +++ b/models/issues/issue_status.go @@ -0,0 +1,104 @@ +// 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'"` + 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" +} + +// ────────────────────────────────────────────────────────────────────── +// Queries +// ────────────────────────────────────────────────────────────────────── + +// GetIssueStatusDefsByOrg returns active status definitions for an org. +func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) { + defs := make([]*IssueStatusDef, 0, 10) + return defs, db.GetEngine(ctx). + Where("org_id = ? AND is_active = ?", orgID, true). + OrderBy("sort_order ASC, id ASC"). + Find(&defs) +} + +// 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 +} + +// DeleteIssueStatusDef deletes a status definition and clears references on issues. +func DeleteIssueStatusDef(ctx context.Context, id int64) error { + // 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 +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 0c0a515881..f40217fc30 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -423,6 +423,7 @@ func prepareMigrationTasks() []*migration { newMigration(343, "Add custom field tables for issue custom fields", v1_27.AddCustomFieldTables), newMigration(344, "Add domain_restriction to license_package table", v1_27.AddDomainRestrictionToLicensePackage), newMigration(345, "Migrate custom fields to org-level with scope", v1_27.MigrateCustomFieldsToOrgLevel), + newMigration(346, "Add issue status definitions table", v1_27.AddIssueStatusDefTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v346.go b/models/migrations/v1_27/v346.go new file mode 100644 index 0000000000..e6583b0aff --- /dev/null +++ b/models/migrations/v1_27/v346.go @@ -0,0 +1,34 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "xorm.io/xorm" +) + +// AddIssueStatusDefTable creates the issue_status_def table and adds +// status_id to the issue table. +func AddIssueStatusDefTable(x *xorm.Engine) error { + 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)"` + Description string `xorm:"TEXT"` + ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"` + SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` + IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"` + CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"` + UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"` + } + if err := x.Sync(new(IssueStatusDef)); err != nil { + return err + } + + // Add status_id column to issue table + type Issue struct { + StatusID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'status_id'"` + } + return x.Sync(new(Issue)) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 2c7c4cd7a0..8b6c158080 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1582,6 +1582,7 @@ "repo.issues.edit": "Edit", "repo.issues.cancel": "Cancel", "repo.issues.save": "Save", + "repo.issues.status": "Status", "repo.issues.label_title": "Name", "repo.issues.label_description": "Description", "repo.issues.label_color": "Color", @@ -2917,6 +2918,21 @@ "org.settings.custom_field_created": "Custom field created.", "org.settings.custom_field_updated": "Custom field updated.", "org.settings.custom_field_deleted": "Custom field deleted.", + "org.settings.issue_statuses": "Issue Statuses", + "org.settings.issue_statuses_desc": "Define custom issue statuses for all repositories in this organization. Statuses appear in the issue sidebar and can automatically close or reopen issues.", + "org.settings.issue_statuses_empty": "No custom issue statuses defined yet.", + "org.settings.issue_status_add": "Add Status", + "org.settings.issue_status_name": "Status Name", + "org.settings.issue_status_color": "Color", + "org.settings.issue_status_description": "Description", + "org.settings.issue_status_closes_issue": "Closes issue", + "org.settings.issue_status_closes_issue_help": "When this status is selected, the issue will be automatically closed.", + "org.settings.issue_status_closes": "Closes", + "org.settings.issue_status_sort_order": "Sort Order", + "org.settings.issue_status_inactive": "Inactive", + "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.update_streams": "Update Server", "org.settings.licensing": "Update Server", "org.settings.licensing_desc": "Manage update feeds and optional license key gating across all repositories in this organization.", diff --git a/routers/web/org/issue_statuses.go b/routers/web/org/issue_statuses.go new file mode 100644 index 0000000000..805c1f9b19 --- /dev/null +++ b/routers/web/org/issue_statuses.go @@ -0,0 +1,112 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package org + +import ( + "net/http" + "strconv" + + issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +const tplOrgIssueStatuses templates.TplName = "org/settings/issue_statuses" + +// SettingsIssueStatuses shows the org-level issue statuses management page. +func SettingsIssueStatuses(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings.issue_statuses") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsIssueStatuses"] = true + + defs, err := issues_model.GetAllIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.ServerError("GetAllIssueStatusDefsByOrg", err) + return + } + ctx.Data["IssueStatuses"] = defs + + ctx.HTML(http.StatusOK, tplOrgIssueStatuses) +} + +// SettingsIssueStatusesCreatePost creates a new org-level issue status. +func SettingsIssueStatusesCreatePost(ctx *context.Context) { + sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order")) + + def := &issues_model.IssueStatusDef{ + OrgID: ctx.Org.Organization.ID, + Name: ctx.FormString("name"), + Color: ctx.FormString("color"), + Description: ctx.FormString("description"), + ClosesIssue: ctx.FormString("closes_issue") == "on", + SortOrder: sortOrder, + IsActive: true, + } + + if def.Name == "" { + ctx.Flash.Error("Status name is required") + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") + return + } + + if err := issues_model.CreateIssueStatusDef(ctx, def); err != nil { + ctx.ServerError("CreateIssueStatusDef", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_status_created")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") +} + +// SettingsIssueStatusesEditPost updates an org-level issue status. +func SettingsIssueStatusesEditPost(ctx *context.Context) { + id := ctx.PathParamInt64("id") + def, err := issues_model.GetIssueStatusDefByID(ctx, id) + if err != nil { + ctx.ServerError("GetIssueStatusDefByID", err) + return + } + if def.OrgID != ctx.Org.Organization.ID { + ctx.NotFound(nil) + return + } + + def.Name = ctx.FormString("name") + def.Color = ctx.FormString("color") + def.Description = ctx.FormString("description") + def.ClosesIssue = ctx.FormString("closes_issue") == "on" + def.IsActive = ctx.FormString("is_active") == "on" + sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order")) + def.SortOrder = sortOrder + + if err := issues_model.UpdateIssueStatusDef(ctx, def); err != nil { + ctx.ServerError("UpdateIssueStatusDef", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_status_updated")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") +} + +// SettingsIssueStatusesDeletePost deletes an org-level issue status. +func SettingsIssueStatusesDeletePost(ctx *context.Context) { + id := ctx.PathParamInt64("id") + def, err := issues_model.GetIssueStatusDefByID(ctx, id) + if err != nil { + ctx.ServerError("GetIssueStatusDefByID", err) + return + } + if def.OrgID != ctx.Org.Organization.ID { + ctx.NotFound(nil) + return + } + + if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil { + ctx.ServerError("DeleteIssueStatusDef", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_status_deleted")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") +} diff --git a/routers/web/repo/issue_custom_status.go b/routers/web/repo/issue_custom_status.go new file mode 100644 index 0000000000..102d76f37c --- /dev/null +++ b/routers/web/repo/issue_custom_status.go @@ -0,0 +1,59 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "fmt" + "net/http" + + issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" + issue_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/issue" +) + +// UpdateIssueCustomStatus handles POST to set a custom status on an issue. +// If the chosen status has ClosesIssue=true, the issue is automatically closed. +// If the chosen status has ClosesIssue=false and the issue is closed, it is reopened. +func UpdateIssueCustomStatus(ctx *context.Context) { + issueID := ctx.PathParamInt64("id") + statusID := ctx.FormInt64("status_id") + + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + // Validate the status belongs to this repo's org (or is being cleared). + if statusID > 0 { + statusDef, err := issues_model.GetIssueStatusDefByID(ctx, statusID) + if err != nil { + ctx.ServerError("GetIssueStatusDefByID", err) + return + } + if statusDef.OrgID != ctx.Repo.Repository.OwnerID { + ctx.NotFound(nil) + return + } + + // Handle automatic close/reopen based on the status definition. + if statusDef.ClosesIssue && !issue.IsClosed { + if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil { + log.Error("UpdateIssueCustomStatus: CloseIssue: %v", err) + } + } else if !statusDef.ClosesIssue && issue.IsClosed { + if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil { + log.Error("UpdateIssueCustomStatus: ReopenIssue: %v", err) + } + } + } + + if err := issues_model.SetIssueStatusID(ctx, issueID, statusID); err != nil { + ctx.ServerError("SetIssueStatusID", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther) +} diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 4803827c4c..e12089d6f2 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -364,6 +364,14 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["CustomFieldValues"] = customFieldValues ctx.Data["CustomFieldOptions"] = fieldOptions + + // Load custom issue status definitions for the sidebar. + issueStatusDefs, isErr := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Repo.Repository.OwnerID) + if isErr != nil { + log.Error("ViewIssue: GetIssueStatusDefsByOrg: %v", isErr) + } + ctx.Data["IssueStatusDefs"] = issueStatusDefs + upload.AddUploadContext(ctx, "comment") if err := issue.LoadAttributes(ctx); err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index 98b7eb3e9f..1f985c4e18 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1067,6 +1067,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{id}/edit", org.SettingsCustomFieldsEditPost) m.Post("/{id}/delete", org.SettingsCustomFieldsDeletePost) }) + m.Group("/issue-statuses", func() { + m.Get("", org.SettingsIssueStatuses) + m.Post("", org.SettingsIssueStatusesCreatePost) + m.Post("/{id}/edit", org.SettingsIssueStatusesEditPost) + m.Post("/{id}/delete", org.SettingsIssueStatusesDeletePost) + }) }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) }, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true})) }, reqSignIn) @@ -1399,6 +1405,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) m.Post("/{id}/custom-fields/{field_id}", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomField) + m.Post("/{id}/custom-status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomStatus) m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin) m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove) diff --git a/templates/org/settings/issue_statuses.tmpl b/templates/org/settings/issue_statuses.tmpl new file mode 100644 index 0000000000..c11396df0e --- /dev/null +++ b/templates/org/settings/issue_statuses.tmpl @@ -0,0 +1,93 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings issue-statuses")}} +

+ {{ctx.Locale.Tr "org.settings.issue_statuses"}} +

+
+

{{ctx.Locale.Tr "org.settings.issue_statuses_desc"}}

+ + {{if .IssueStatuses}} + + + + + + + + + + + + {{range .IssueStatuses}} + + + + + + + + {{end}} + +
{{ctx.Locale.Tr "org.settings.issue_status_color"}}{{ctx.Locale.Tr "org.settings.issue_status_name"}}{{ctx.Locale.Tr "org.settings.issue_status_closes_issue"}}{{ctx.Locale.Tr "org.settings.issue_status_sort_order"}}
+ {{if .Color}} + + {{else}} + - + {{end}} + + {{.Name}} + {{if not .IsActive}}{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}{{end}} + {{if .Description}}
{{.Description}}{{end}} +
+ {{if .ClosesIssue}} + {{ctx.Locale.Tr "org.settings.issue_status_closes"}} + {{else}} + - + {{end}} + {{.SortOrder}} +
+ {{$.CsrfTokenHtml}} + +
+
+ {{else}} +
+

{{ctx.Locale.Tr "org.settings.issue_statuses_empty"}}

+
+ {{end}} + +
+ +
{{ctx.Locale.Tr "org.settings.issue_status_add"}}
+
+ {{.CsrfTokenHtml}} +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+

{{ctx.Locale.Tr "org.settings.issue_status_closes_issue_help"}}

+
+
+ +
+
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 85417f1870..09be57f477 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -31,6 +31,9 @@ {{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "org.settings.custom_fields"}} + + {{svg "octicon-tasklist"}} {{ctx.Locale.Tr "org.settings.issue_statuses"}} + {{if .EnableActions}}
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}} diff --git a/templates/repo/issue/sidebar/issue_status.tmpl b/templates/repo/issue/sidebar/issue_status.tmpl new file mode 100644 index 0000000000..98abd21458 --- /dev/null +++ b/templates/repo/issue/sidebar/issue_status.tmpl @@ -0,0 +1,33 @@ +{{if .IssueStatusDefs}} +
+
+ {{ctx.Locale.Tr "repo.issues.status"}} + {{$canModify := .HasIssuesOrPullsWritePermission}} + {{if $canModify}} +
+ {{$.CsrfTokenHtml}} + +
+ {{else}} + {{$found := false}} + {{range .IssueStatusDefs}} + {{if eq .ID $.Issue.StatusID}} + {{if .Color}}{{end}} + {{.Name}} + {{$found = true}} + {{end}} + {{end}} + {{if not $found}} + + {{end}} + {{end}} +
+{{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 9f064ce6ee..e20ef6ddae 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -7,6 +7,8 @@ {{template "repo/issue/sidebar/label_list" $.IssuePageMetaData}} + {{template "repo/issue/sidebar/issue_status" $}} + {{template "repo/issue/sidebar/custom_fields" $}} {{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}} -- 2.52.0