From 55c2f81c582a8e50596a196313625b0b99ff7f88 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 11:52:44 -0500 Subject: [PATCH] feat(issues): org-level priority field with customizable levels (#509) Add org-level issue priority definitions that appear in the issue sidebar. Each priority has a name, color, sort order, and optional default flag. Follows the same architecture as custom statuses (#502). Includes: - IssuePriorityDef model with CRUD operations - Migration v348 adding issue_priority_def table + priority_id on issues - Org settings UI for managing priorities - Issue sidebar dropdown for selecting priority Co-Authored-By: Claude Opus 4.6 (1M context) --- models/issues/issue.go | 2 + models/issues/issue_priority.go | 91 ++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_27/v348.go | 33 ++++++ options/locale/locale_en-US.json | 15 +++ routers/web/org/issue_priorities.go | 112 ++++++++++++++++++ routers/web/repo/issue_custom_priority.go | 44 +++++++ routers/web/repo/issue_view.go | 7 ++ routers/web/web.go | 7 ++ templates/org/settings/issue_priorities.tmpl | 93 +++++++++++++++ templates/org/settings/navbar.tmpl | 3 + .../repo/issue/sidebar/issue_priority.tmpl | 33 ++++++ .../repo/issue/view_content/sidebar.tmpl | 2 + 13 files changed, 443 insertions(+) create mode 100644 models/issues/issue_priority.go create mode 100644 models/migrations/v1_27/v348.go create mode 100644 routers/web/org/issue_priorities.go create mode 100644 routers/web/repo/issue_custom_priority.go create mode 100644 templates/org/settings/issue_priorities.tmpl create mode 100644 templates/repo/issue/sidebar/issue_priority.tmpl diff --git a/models/issues/issue.go b/models/issues/issue.go index 1992008b2f..bc1f43d322 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -78,6 +78,8 @@ type Issue struct { IsClosed bool `xorm:"INDEX"` StatusID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'status_id'"` Status *IssueStatusDef `xorm:"-"` + PriorityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'priority_id'"` + Priority *IssuePriorityDef `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_priority.go b/models/issues/issue_priority.go new file mode 100644 index 0000000000..3b6bcee322 --- /dev/null +++ b/models/issues/issue_priority.go @@ -0,0 +1,91 @@ +// 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(IssuePriorityDef)) +} + +// IssuePriorityDef defines a custom issue priority at the org level. +type IssuePriorityDef 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"` + SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` + IsDefault bool `xorm:"NOT NULL DEFAULT false 'is_default'"` + 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 (IssuePriorityDef) TableName() string { + return "issue_priority_def" +} + +// GetIssuePriorityDefsByOrg returns active priority definitions for an org. +func GetIssuePriorityDefsByOrg(ctx context.Context, orgID int64) ([]*IssuePriorityDef, error) { + defs := make([]*IssuePriorityDef, 0, 10) + return defs, db.GetEngine(ctx). + Where("org_id = ? AND is_active = ?", orgID, true). + OrderBy("sort_order ASC, id ASC"). + Find(&defs) +} + +// GetAllIssuePriorityDefsByOrg returns all priority definitions (including inactive). +func GetAllIssuePriorityDefsByOrg(ctx context.Context, orgID int64) ([]*IssuePriorityDef, error) { + defs := make([]*IssuePriorityDef, 0, 10) + return defs, db.GetEngine(ctx). + Where("org_id = ?", orgID). + OrderBy("sort_order ASC, id ASC"). + Find(&defs) +} + +// GetIssuePriorityDefByID returns a single priority definition. +func GetIssuePriorityDefByID(ctx context.Context, id int64) (*IssuePriorityDef, error) { + def := new(IssuePriorityDef) + has, err := db.GetEngine(ctx).ID(id).Get(def) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "IssuePriorityDef", ID: id} + } + return def, nil +} + +// CreateIssuePriorityDef creates a new priority definition. +func CreateIssuePriorityDef(ctx context.Context, def *IssuePriorityDef) error { + _, err := db.GetEngine(ctx).Insert(def) + return err +} + +// UpdateIssuePriorityDef updates a priority definition. +func UpdateIssuePriorityDef(ctx context.Context, def *IssuePriorityDef) error { + _, err := db.GetEngine(ctx).ID(def.ID).AllCols().Update(def) + return err +} + +// DeleteIssuePriorityDef deletes a priority definition and clears references on issues. +func DeleteIssuePriorityDef(ctx context.Context, id int64) error { + if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET priority_id = 0 WHERE priority_id = ?", id); err != nil { + return err + } + _, err := db.GetEngine(ctx).ID(id).Delete(new(IssuePriorityDef)) + return err +} + +// SetIssuePriorityID updates the priority_id on an issue. +func SetIssuePriorityID(ctx context.Context, issueID, priorityID int64) error { + _, err := db.GetEngine(ctx).Exec("UPDATE issue SET priority_id = ? WHERE id = ?", priorityID, issueID) + return err +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 6af0731f31..47bf3d8b9c 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -425,6 +425,7 @@ func prepareMigrationTasks() []*migration { newMigration(345, "Migrate custom fields to org-level with scope", v1_27.MigrateCustomFieldsToOrgLevel), newMigration(346, "Add issue status definitions table", v1_27.AddIssueStatusDefTable), newMigration(347, "Add repo manifest table", v1_27.AddRepoManifestTable), + newMigration(348, "Add issue priority definitions table", v1_27.AddIssuePriorityDefTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v348.go b/models/migrations/v1_27/v348.go new file mode 100644 index 0000000000..17ccfdccf2 --- /dev/null +++ b/models/migrations/v1_27/v348.go @@ -0,0 +1,33 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "xorm.io/xorm" +) + +// AddIssuePriorityDefTable creates the issue_priority_def table and adds +// priority_id to the issue table. +func AddIssuePriorityDefTable(x *xorm.Engine) error { + type IssuePriorityDef 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"` + SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` + IsDefault bool `xorm:"NOT NULL DEFAULT false 'is_default'"` + 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(IssuePriorityDef)); err != nil { + return err + } + + type Issue struct { + PriorityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'priority_id'"` + } + return x.Sync(new(Issue)) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index e19b9802eb..f61f9eb038 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1583,6 +1583,7 @@ "repo.issues.cancel": "Cancel", "repo.issues.save": "Save", "repo.issues.status": "Status", + "repo.issues.priority": "Priority", "repo.issues.label_title": "Name", "repo.issues.label_description": "Description", "repo.issues.label_color": "Color", @@ -2952,6 +2953,20 @@ "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_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.", + "org.settings.issue_priority_add": "Add Priority", + "org.settings.issue_priority_name": "Priority Name", + "org.settings.issue_priority_color": "Color", + "org.settings.issue_priority_description": "Description", + "org.settings.issue_priority_default": "Default", + "org.settings.issue_priority_default_help": "Auto-assigned to new issues.", + "org.settings.issue_priority_sort_order": "Sort Order", + "org.settings.issue_priority_inactive": "Inactive", + "org.settings.issue_priority_created": "Issue priority created.", + "org.settings.issue_priority_updated": "Issue priority updated.", + "org.settings.issue_priority_deleted": "Issue priority 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_priorities.go b/routers/web/org/issue_priorities.go new file mode 100644 index 0000000000..ddb176133c --- /dev/null +++ b/routers/web/org/issue_priorities.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 tplOrgIssuePriorities templates.TplName = "org/settings/issue_priorities" + +// SettingsIssuePriorities shows the org-level issue priorities management page. +func SettingsIssuePriorities(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings.issue_priorities") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsIssuePriorities"] = true + + defs, err := issues_model.GetAllIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.ServerError("GetAllIssuePriorityDefsByOrg", err) + return + } + ctx.Data["IssuePriorities"] = defs + + ctx.HTML(http.StatusOK, tplOrgIssuePriorities) +} + +// SettingsIssuePrioritiesCreatePost creates a new org-level issue priority. +func SettingsIssuePrioritiesCreatePost(ctx *context.Context) { + sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order")) + + def := &issues_model.IssuePriorityDef{ + OrgID: ctx.Org.Organization.ID, + Name: ctx.FormString("name"), + Color: ctx.FormString("color"), + Description: ctx.FormString("description"), + SortOrder: sortOrder, + IsDefault: ctx.FormString("is_default") == "on", + IsActive: true, + } + + if def.Name == "" { + ctx.Flash.Error("Priority name is required") + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities") + return + } + + if err := issues_model.CreateIssuePriorityDef(ctx, def); err != nil { + ctx.ServerError("CreateIssuePriorityDef", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_priority_created")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities") +} + +// SettingsIssuePrioritiesEditPost updates an org-level issue priority. +func SettingsIssuePrioritiesEditPost(ctx *context.Context) { + id := ctx.PathParamInt64("id") + def, err := issues_model.GetIssuePriorityDefByID(ctx, id) + if err != nil { + ctx.ServerError("GetIssuePriorityDefByID", 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.IsDefault = ctx.FormString("is_default") == "on" + def.IsActive = ctx.FormString("is_active") == "on" + sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order")) + def.SortOrder = sortOrder + + if err := issues_model.UpdateIssuePriorityDef(ctx, def); err != nil { + ctx.ServerError("UpdateIssuePriorityDef", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_priority_updated")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities") +} + +// SettingsIssuePrioritiesDeletePost deletes an org-level issue priority. +func SettingsIssuePrioritiesDeletePost(ctx *context.Context) { + id := ctx.PathParamInt64("id") + def, err := issues_model.GetIssuePriorityDefByID(ctx, id) + if err != nil { + ctx.ServerError("GetIssuePriorityDefByID", err) + return + } + if def.OrgID != ctx.Org.Organization.ID { + ctx.NotFound(nil) + return + } + + if err := issues_model.DeleteIssuePriorityDef(ctx, id); err != nil { + ctx.ServerError("DeleteIssuePriorityDef", err) + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_priority_deleted")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities") +} diff --git a/routers/web/repo/issue_custom_priority.go b/routers/web/repo/issue_custom_priority.go new file mode 100644 index 0000000000..ff8f8cd975 --- /dev/null +++ b/routers/web/repo/issue_custom_priority.go @@ -0,0 +1,44 @@ +// 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/services/context" +) + +// UpdateIssueCustomPriority handles POST to set a custom priority on an issue. +func UpdateIssueCustomPriority(ctx *context.Context) { + issueID := ctx.PathParamInt64("id") + priorityID := ctx.FormInt64("priority_id") + + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + // Validate the priority belongs to this repo's org. + if priorityID > 0 { + priorityDef, err := issues_model.GetIssuePriorityDefByID(ctx, priorityID) + if err != nil { + ctx.ServerError("GetIssuePriorityDefByID", err) + return + } + if priorityDef.OrgID != ctx.Repo.Repository.OwnerID { + ctx.NotFound(nil) + return + } + } + + if err := issues_model.SetIssuePriorityID(ctx, issueID, priorityID); err != nil { + ctx.ServerError("SetIssuePriorityID", 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 e12089d6f2..1295032f1a 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -372,6 +372,13 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["IssueStatusDefs"] = issueStatusDefs + // Load custom issue priority definitions for the sidebar. + issuePriorityDefs, ipErr := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Repo.Repository.OwnerID) + if ipErr != nil { + log.Error("ViewIssue: GetIssuePriorityDefsByOrg: %v", ipErr) + } + ctx.Data["IssuePriorityDefs"] = issuePriorityDefs + upload.AddUploadContext(ctx, "comment") if err := issue.LoadAttributes(ctx); err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index f50e1e076f..bbc0371140 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1073,6 +1073,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{id}/edit", org.SettingsIssueStatusesEditPost) m.Post("/{id}/delete", org.SettingsIssueStatusesDeletePost) }) + m.Group("/issue-priorities", func() { + m.Get("", org.SettingsIssuePriorities) + m.Post("", org.SettingsIssuePrioritiesCreatePost) + m.Post("/{id}/edit", org.SettingsIssuePrioritiesEditPost) + m.Post("/{id}/delete", org.SettingsIssuePrioritiesDeletePost) + }) }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) }, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true})) }, reqSignIn) @@ -1407,6 +1413,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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("/{id}/custom-priority", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomPriority) 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_priorities.tmpl b/templates/org/settings/issue_priorities.tmpl new file mode 100644 index 0000000000..13eb1a2b6f --- /dev/null +++ b/templates/org/settings/issue_priorities.tmpl @@ -0,0 +1,93 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings issue-priorities")}} +

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

+
+

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

+ + {{if .IssuePriorities}} + + + + + + + + + + + + {{range .IssuePriorities}} + + + + + + + + {{end}} + +
{{ctx.Locale.Tr "org.settings.issue_priority_color"}}{{ctx.Locale.Tr "org.settings.issue_priority_name"}}{{ctx.Locale.Tr "org.settings.issue_priority_default"}}{{ctx.Locale.Tr "org.settings.issue_priority_sort_order"}}
+ {{if .Color}} + + {{else}} + - + {{end}} + + {{.Name}} + {{if not .IsActive}}{{ctx.Locale.Tr "org.settings.issue_priority_inactive"}}{{end}} + {{if .Description}}
{{.Description}}{{end}} +
+ {{if .IsDefault}} + {{ctx.Locale.Tr "org.settings.issue_priority_default"}} + {{else}} + - + {{end}} + {{.SortOrder}} +
+ {{$.CsrfTokenHtml}} + +
+
+ {{else}} +
+

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

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

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

+
+
+ +
+
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 09be57f477..8a3a42283f 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -34,6 +34,9 @@ {{svg "octicon-tasklist"}} {{ctx.Locale.Tr "org.settings.issue_statuses"}} + + {{svg "octicon-flame"}} {{ctx.Locale.Tr "org.settings.issue_priorities"}} + {{if .EnableActions}}
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}} diff --git a/templates/repo/issue/sidebar/issue_priority.tmpl b/templates/repo/issue/sidebar/issue_priority.tmpl new file mode 100644 index 0000000000..72edcdafab --- /dev/null +++ b/templates/repo/issue/sidebar/issue_priority.tmpl @@ -0,0 +1,33 @@ +{{if .IssuePriorityDefs}} +
+
+ {{ctx.Locale.Tr "repo.issues.priority"}} + {{$canModify := .HasIssuesOrPullsWritePermission}} + {{if $canModify}} +
+ {{$.CsrfTokenHtml}} + +
+ {{else}} + {{$found := false}} + {{range .IssuePriorityDefs}} + {{if eq .ID $.Issue.PriorityID}} + {{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 e20ef6ddae..59a0c18b0a 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -9,6 +9,8 @@ {{template "repo/issue/sidebar/issue_status" $}} + {{template "repo/issue/sidebar/issue_priority" $}} + {{template "repo/issue/sidebar/custom_fields" $}} {{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}} -- 2.52.0