From dd1454c3cfe03802243d91e1f0a86f7c70e7bf08 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 17:12:44 -0500 Subject: [PATCH] feat(issues): first-class Type field + status/priority/type badges in issue list - IssueTypeDef model with auto-seed defaults (Bug, Feature, Enhancement, Task, Documentation, Security) - Migration v350 adding issue_type_def table + type_id on issues - Type dropdown in issue sidebar - Type, Priority, Status colored badges in issue list view - Status/Priority/Type definitions loaded in issue list handler --- models/issues/issue.go | 2 + models/issues/issue_type.go | 114 ++++++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_27/v351.go | 29 +++++ options/locale/locale_en-US.json | 1 + routers/web/repo/issue_custom_type.go | 43 +++++++ routers/web/repo/issue_list.go | 8 ++ routers/web/repo/issue_view.go | 7 ++ routers/web/web.go | 1 + templates/repo/issue/sidebar/issue_type.tmpl | 33 +++++ .../repo/issue/view_content/sidebar.tmpl | 2 + templates/shared/issuelist.tmpl | 3 + 12 files changed, 244 insertions(+) create mode 100644 models/issues/issue_type.go create mode 100644 models/migrations/v1_27/v351.go create mode 100644 routers/web/repo/issue_custom_type.go create mode 100644 templates/repo/issue/sidebar/issue_type.tmpl diff --git a/models/issues/issue.go b/models/issues/issue.go index 711168e772..41c101d90f 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -80,6 +80,8 @@ type Issue struct { Status *IssueStatusDef `xorm:"-"` PriorityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'priority_id'"` PriorityDef *IssuePriorityDef `xorm:"-"` + TypeID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'type_id'"` + TypeDef *IssueTypeDef `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_type.go b/models/issues/issue_type.go new file mode 100644 index 0000000000..8ba8cbbe6b --- /dev/null +++ b/models/issues/issue_type.go @@ -0,0 +1,114 @@ +// 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(IssueTypeDef)) +} + +// IssueTypeDef defines a custom issue type at the org level. +type IssueTypeDef 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 (IssueTypeDef) TableName() string { + return "issue_type_def" +} + +// GetIssueTypeDefsByOrg returns active type definitions for an org. +// Auto-seeds defaults if none exist. +func GetIssueTypeDefsByOrg(ctx context.Context, orgID int64) ([]*IssueTypeDef, error) { + defs := make([]*IssueTypeDef, 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 := seedDefaultIssueTypes(ctx, orgID); err != nil { + return defs, nil + } + return GetIssueTypeDefsByOrg(ctx, orgID) + } + return defs, nil +} + +// GetAllIssueTypeDefsByOrg returns all type definitions (including inactive). +func GetAllIssueTypeDefsByOrg(ctx context.Context, orgID int64) ([]*IssueTypeDef, error) { + defs := make([]*IssueTypeDef, 0, 10) + return defs, db.GetEngine(ctx). + Where("org_id = ?", orgID). + OrderBy("sort_order ASC, id ASC"). + Find(&defs) +} + +// GetIssueTypeDefByID returns a single type definition. +func GetIssueTypeDefByID(ctx context.Context, id int64) (*IssueTypeDef, error) { + def := new(IssueTypeDef) + has, err := db.GetEngine(ctx).ID(id).Get(def) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "IssueTypeDef", ID: id} + } + return def, nil +} + +func CreateIssueTypeDef(ctx context.Context, def *IssueTypeDef) error { + _, err := db.GetEngine(ctx).Insert(def) + return err +} + +func UpdateIssueTypeDef(ctx context.Context, def *IssueTypeDef) error { + _, err := db.GetEngine(ctx).ID(def.ID).AllCols().Update(def) + return err +} + +func DeleteIssueTypeDef(ctx context.Context, id int64) error { + if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET type_id = 0 WHERE type_id = ?", id); err != nil { + return err + } + _, err := db.GetEngine(ctx).ID(id).Delete(new(IssueTypeDef)) + return err +} + +func SetIssueTypeID(ctx context.Context, issueID, typeID int64) error { + _, err := db.GetEngine(ctx).Exec("UPDATE issue SET type_id = ? WHERE id = ?", typeID, issueID) + return err +} + +func seedDefaultIssueTypes(ctx context.Context, orgID int64) error { + defaults := []*IssueTypeDef{ + {OrgID: orgID, Name: "Bug", Color: "#dc2626", SortOrder: 1, IsActive: true}, + {OrgID: orgID, Name: "Feature", Color: "#2563eb", SortOrder: 2, IsDefault: true, IsActive: true}, + {OrgID: orgID, Name: "Enhancement", Color: "#16a34a", SortOrder: 3, IsActive: true}, + {OrgID: orgID, Name: "Task", Color: "#6b7280", SortOrder: 4, IsActive: true}, + {OrgID: orgID, Name: "Documentation", Color: "#8b5cf6", SortOrder: 5, IsActive: true}, + {OrgID: orgID, Name: "Security", Color: "#e11d48", SortOrder: 6, IsActive: true}, + } + for _, d := range defaults { + if _, err := db.GetEngine(ctx).Insert(d); err != nil { + return err + } + } + return nil +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index ffde9bbcf7..83b3b8ee04 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -427,6 +427,7 @@ func prepareMigrationTasks() []*migration { newMigration(347, "Add repo manifest table", v1_27.AddRepoManifestTable), newMigration(348, "Add issue priority definitions table", v1_27.AddIssuePriorityDefTable), newMigration(349, "Add security scanning tables", v1_27.AddSecurityScanningTables), + newMigration(350, "Add issue type definitions table", v1_27.AddIssueTypeDefTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v351.go b/models/migrations/v1_27/v351.go new file mode 100644 index 0000000000..ce25b2a669 --- /dev/null +++ b/models/migrations/v1_27/v351.go @@ -0,0 +1,29 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import "xorm.io/xorm" + +// AddIssueTypeDefTable creates the issue_type_def table and adds type_id to issues. +func AddIssueTypeDefTable(x *xorm.Engine) error { + type IssueTypeDef 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(IssueTypeDef)); err != nil { + return err + } + type Issue struct { + TypeID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'type_id'"` + } + return x.Sync(new(Issue)) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 6e41a67e17..5ede6e91cf 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1584,6 +1584,7 @@ "repo.issues.save": "Save", "repo.issues.status": "Status", "repo.issues.priority": "Priority", + "repo.issues.type": "Type", "repo.issues.label_title": "Name", "repo.issues.label_description": "Description", "repo.issues.label_color": "Color", diff --git a/routers/web/repo/issue_custom_type.go b/routers/web/repo/issue_custom_type.go new file mode 100644 index 0000000000..123f2c6921 --- /dev/null +++ b/routers/web/repo/issue_custom_type.go @@ -0,0 +1,43 @@ +// 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" +) + +// UpdateIssueCustomType handles POST to set a custom type on an issue. +func UpdateIssueCustomType(ctx *context.Context) { + issueID := ctx.PathParamInt64("id") + typeID := ctx.FormInt64("type_id") + + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if typeID > 0 { + typeDef, err := issues_model.GetIssueTypeDefByID(ctx, typeID) + if err != nil { + ctx.ServerError("GetIssueTypeDefByID", err) + return + } + if typeDef.OrgID != ctx.Repo.Repository.OwnerID { + ctx.NotFound(nil) + return + } + } + + if err := issues_model.SetIssueTypeID(ctx, issueID, typeID); err != nil { + ctx.ServerError("SetIssueTypeID", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther) +} diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 4552ea610a..d0e047162c 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -536,6 +536,14 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI } ctx.Data["CustomFieldDefs"] = customFieldDefs ctx.Data["CustomFieldFilters"] = customFieldFilters + + // Load first-class field definitions for issue list badges + issueStatusDefs, _ := issues_model.GetIssueStatusDefsByOrg(ctx, repo.OwnerID) + ctx.Data["IssueStatusDefs"] = issueStatusDefs + issuePriorityDefs, _ := issues_model.GetIssuePriorityDefsByOrg(ctx, repo.OwnerID) + ctx.Data["IssuePriorityDefs"] = issuePriorityDefs + issueTypeDefs, _ := issues_model.GetIssueTypeDefsByOrg(ctx, repo.OwnerID) + ctx.Data["IssueTypeDefs"] = issueTypeDefs // Build a query string fragment for cf_ params so they survive pagination/sort changes. cfQuery := make(url.Values) for fieldID, value := range customFieldFilters { diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 1295032f1a..43ebdf62e6 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -379,6 +379,13 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["IssuePriorityDefs"] = issuePriorityDefs + // Load custom issue type definitions for the sidebar. + issueTypeDefs, itErr := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Repo.Repository.OwnerID) + if itErr != nil { + log.Error("ViewIssue: GetIssueTypeDefsByOrg: %v", itErr) + } + ctx.Data["IssueTypeDefs"] = issueTypeDefs + upload.AddUploadContext(ctx, "comment") if err := issue.LoadAttributes(ctx); err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index be690db036..27ec398fc3 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1419,6 +1419,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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("/{id}/custom-type", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomType) m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin) m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove) diff --git a/templates/repo/issue/sidebar/issue_type.tmpl b/templates/repo/issue/sidebar/issue_type.tmpl new file mode 100644 index 0000000000..42fb18277f --- /dev/null +++ b/templates/repo/issue/sidebar/issue_type.tmpl @@ -0,0 +1,33 @@ +{{if .IssueTypeDefs}} +
+
+ {{ctx.Locale.Tr "repo.issues.type"}} + {{$canModify := and .FieldEditFlags .FieldEditFlags.CustomFields}} + {{if $canModify}} +
+ {{$.CsrfTokenHtml}} + +
+ {{else}} + {{$found := false}} + {{range .IssueTypeDefs}} + {{if eq .ID $.Issue.TypeID}} + {{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 b4ec884c26..18a1552ffc 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_priority" $}} + {{template "repo/issue/sidebar/issue_type" $}} + {{template "repo/issue/sidebar/custom_fields" $}} {{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}} diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index a45f997e8e..78bd635af8 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -26,6 +26,9 @@ {{range .Labels}} {{ctx.RenderUtils.RenderLabel .}} {{end}} + {{if and .TypeID $.IssueTypeDefs}}{{range $.IssueTypeDefs}}{{if eq .ID $.TypeID}}{{.Name}}{{end}}{{end}}{{end}} + {{if and .PriorityID $.IssuePriorityDefs}}{{range $.IssuePriorityDefs}}{{if eq .ID $.PriorityID}}{{.Name}}{{end}}{{end}}{{end}} + {{if and .StatusID $.IssueStatusDefs}}{{range $.IssueStatusDefs}}{{if eq .ID $.StatusID}}{{.Name}}{{end}}{{end}}{{end}} {{if .TotalTrackedTime}} -- 2.52.0