feat(issues): first-class Type field + list badges #543
@@ -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:"-"`
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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))
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{if .IssueTypeDefs}}
|
||||
<div class="divider"></div>
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||
<span class="text grey tw-text-sm">{{ctx.Locale.Tr "repo.issues.type"}}</span>
|
||||
{{$canModify := and .FieldEditFlags .FieldEditFlags.CustomFields}}
|
||||
{{if $canModify}}
|
||||
<form method="post" action="{{.RepoLink}}/issues/{{.Issue.ID}}/custom-type" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<select name="type_id" class="ui compact mini dropdown tw-max-w-48" onchange="this.form.submit()">
|
||||
<option value="0">-</option>
|
||||
{{range .IssueTypeDefs}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.Issue.TypeID}}selected{{end}}
|
||||
{{if .Color}}style="border-left: 3px solid {{.Color}}"{{end}}>
|
||||
{{.Name}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</form>
|
||||
{{else}}
|
||||
{{$found := false}}
|
||||
{{range .IssueTypeDefs}}
|
||||
{{if eq .ID $.Issue.TypeID}}
|
||||
{{if .Color}}<span class="tw-inline-block tw-w-3 tw-h-3 tw-rounded" style="background-color: {{.Color}}"></span>{{end}}
|
||||
<span class="tw-text-sm">{{.Name}}</span>
|
||||
{{$found = true}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if not $found}}
|
||||
<span class="tw-text-sm text grey">-</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{{range .Labels}}
|
||||
<a href="?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.RenderUtils.RenderLabel .}}</a>
|
||||
{{end}}
|
||||
{{if and .TypeID $.IssueTypeDefs}}{{range $.IssueTypeDefs}}{{if eq .ID $.TypeID}}<span class="ui mini label" {{if .Color}}style="background-color: {{.Color}}; color: white"{{end}}>{{.Name}}</span>{{end}}{{end}}{{end}}
|
||||
{{if and .PriorityID $.IssuePriorityDefs}}{{range $.IssuePriorityDefs}}{{if eq .ID $.PriorityID}}<span class="ui mini label" {{if .Color}}style="background-color: {{.Color}}; color: white"{{end}}>{{.Name}}</span>{{end}}{{end}}{{end}}
|
||||
{{if and .StatusID $.IssueStatusDefs}}{{range $.IssueStatusDefs}}{{if eq .ID $.StatusID}}<span class="ui mini label" {{if .Color}}style="background-color: {{.Color}}; color: white"{{end}}>{{.Name}}</span>{{end}}{{end}}{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{if .TotalTrackedTime}}
|
||||
|
||||
Reference in New Issue
Block a user