diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 694b918755..63b39db81e 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -70,6 +70,15 @@ func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOption // applySorts sort an issues-related session based on the provided // sortType string func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) { + // Since this sortType is dynamically created, it has to be treated specially. + if strings.HasPrefix(sortType, "scope-") { + scope := strings.TrimPrefix(sortType, "scope-") + sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id") + sess.Join("LEFT", "label", "label.id = issue_label.label_id and label.name LIKE ?", scope+"/%") + sess.Asc("label.exclusive_order").Desc("issue.id") + return // EARLY RETURN + } + switch sortType { case "oldest": sess.Asc("issue.created_unix").Asc("issue.id") diff --git a/models/issues/label.go b/models/issues/label.go index b9d24bbe99..de1366f065 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -87,6 +87,7 @@ type Label struct { OrgID int64 `xorm:"INDEX"` Name string Exclusive bool + ExclusiveOrder int `xorm:"DEFAULT 0"` Description string Color string `xorm:"VARCHAR(7)"` NumIssues int @@ -236,7 +237,7 @@ func UpdateLabel(ctx context.Context, l *Label) error { } l.Color = color - return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "archived_unix") + return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix") } // DeleteLabel delete a label diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 87d674a440..a94a1a6605 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -374,6 +374,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.23.0-rc0 ends at migration ID number 311 (database version 312) newMigration(312, "Add DeleteBranchAfterMerge to AutoMerge", v1_24.AddDeleteBranchAfterMergeForAutoMerge), newMigration(313, "Move PinOrder from issue table to a new table issue_pin", v1_24.MovePinOrderToTableIssuePin), + newMigration(314, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), } return preparedMigrations } diff --git a/models/migrations/v1_24/v314.go b/models/migrations/v1_24/v314.go new file mode 100644 index 0000000000..cda6724586 --- /dev/null +++ b/models/migrations/v1_24/v314.go @@ -0,0 +1,16 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_24 //nolint + +import ( + "xorm.io/xorm" +) + +func AddExclusiveOrderColumnToLabelTable(x *xorm.Engine) error { + type Label struct { + ExclusiveOrder int64 `xorm:"DEFAULT 0"` + } + + return x.Sync(new(Label)) +} diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go index 87ce398a20..25e0be8a82 100644 --- a/modules/indexer/issues/db/options.go +++ b/modules/indexer/issues/db/options.go @@ -6,6 +6,7 @@ package db import ( "context" "fmt" + "strings" "code.gitea.io/gitea/models/db" issue_model "code.gitea.io/gitea/models/issues" @@ -34,7 +35,11 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m case internal.SortByDeadlineAsc: sortType = "nearduedate" default: - sortType = "newest" + if strings.HasPrefix(string(options.SortBy), "scope-") { + sortType = string(options.SortBy) + } else { + sortType = "newest" + } } // See the comment of issues_model.SearchOptions for the reason why we need to convert diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index 4f6ad96d22..f987dd28b6 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -4,8 +4,11 @@ package issues import ( + "strings" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/optional" ) @@ -99,7 +102,11 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp // Unsupported sort type for search fallthrough default: - searchOpt.SortBy = SortByUpdatedDesc + if strings.HasPrefix(opts.SortType, "scope-") { + searchOpt.SortBy = internal.SortBy(opts.SortType) + } else { + searchOpt.SortBy = SortByUpdatedDesc + } } return searchOpt diff --git a/modules/label/label.go b/modules/label/label.go index d3ef0e1dc9..ce028aa9f3 100644 --- a/modules/label/label.go +++ b/modules/label/label.go @@ -14,10 +14,11 @@ var colorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$") // Label represents label information loaded from template type Label struct { - Name string `yaml:"name"` - Color string `yaml:"color"` - Description string `yaml:"description,omitempty"` - Exclusive bool `yaml:"exclusive,omitempty"` + Name string `yaml:"name"` + Color string `yaml:"color"` + Description string `yaml:"description,omitempty"` + Exclusive bool `yaml:"exclusive,omitempty"` + ExclusiveOrder int `yaml:"exclusive_order,omitempty"` } // NormalizeColor normalizes a color string to a 6-character hex code diff --git a/modules/repository/init.go b/modules/repository/init.go index 24602ae090..097dd596e5 100644 --- a/modules/repository/init.go +++ b/modules/repository/init.go @@ -154,10 +154,11 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg labels := make([]*issues_model.Label, len(list)) for i := 0; i < len(list); i++ { labels[i] = &issues_model.Label{ - Name: list[i].Name, - Exclusive: list[i].Exclusive, - Description: list[i].Description, - Color: list[i].Color, + Name: list[i].Name, + Exclusive: list[i].Exclusive, + ExclusiveOrder: list[i].ExclusiveOrder, + Description: list[i].Description, + Color: list[i].Color, } if isOrg { labels[i].OrgID = id diff --git a/options/label/Advanced.yaml b/options/label/Advanced.yaml index b1ecdd6d93..55ac91116e 100644 --- a/options/label/Advanced.yaml +++ b/options/label/Advanced.yaml @@ -22,49 +22,60 @@ labels: description: Breaking change that won't be backward compatible - name: "Reviewed/Duplicate" exclusive: true + exclusive_order: 50 color: 616161 description: This issue or pull request already exists - name: "Reviewed/Invalid" exclusive: true + exclusive_order: 100 color: 546e7a description: Invalid issue - name: "Reviewed/Confirmed" exclusive: true + exclusive_order: 0 color: 795548 description: Issue has been confirmed - name: "Reviewed/Won't Fix" exclusive: true + exclusive_order: 75 color: eeeeee description: This issue won't be fixed - name: "Status/Need More Info" exclusive: true + exclusive_order: 25 color: 424242 description: Feedback is required to reproduce issue or to continue work - name: "Status/Blocked" exclusive: true + exclusive_order: 0 color: 880e4f description: Something is blocking this issue or pull request - name: "Status/Abandoned" exclusive: true + exclusive_order: 100 color: "222222" description: Somebody has started to work on this but abandoned work - name: "Priority/Critical" exclusive: true + exclusive_order: 0 color: b71c1c description: The priority is critical priority: critical - name: "Priority/High" exclusive: true + exclusive_order: 1 color: d32f2f description: The priority is high priority: high - name: "Priority/Medium" exclusive: true + exclusive_order: 2 color: e64a19 description: The priority is medium priority: medium - name: "Priority/Low" exclusive: true + exclusive_order: 3 color: 4caf50 description: The priority is low priority: low diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c2c5b07b65..e4e76b009d 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1644,6 +1644,8 @@ issues.label_archived_filter = Show archived labels issues.label_archive_tooltip = Archived labels are excluded by default from the suggestions when searching by label. issues.label_exclusive_desc = Name the label scope/item to make it mutually exclusive with other scope/ labels. issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request. +issues.label_exclusive_order = Sort Order +issues.label_exclusive_order_tooltip = Exclusive labels in the same scope will be sorted according to this numeric order. issues.label_count = %d labels issues.label_open_issues = %d open issues/pull requests issues.label_edit = Edit diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go index 62c0128f19..10e3e7ee51 100644 --- a/routers/web/repo/issue_label.go +++ b/routers/web/repo/issue_label.go @@ -139,6 +139,7 @@ func UpdateLabel(ctx *context.Context) { } l.Name = form.Title l.Exclusive = form.Exclusive + l.ExclusiveOrder = form.ExclusiveOrder l.Description = form.Description l.Color = form.Color diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index a65ae77795..bdd81a1f1f 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -6,7 +6,10 @@ package repo import ( "bytes" "fmt" + "maps" "net/http" + "slices" + "sort" "strconv" "strings" @@ -459,6 +462,25 @@ func UpdateIssueStatus(ctx *context.Context) { ctx.JSONOK() } +func renderExclusiveLabelScopes(ctx *context.Context) { + labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetAllRepoLabels", err) + return + } + scopeSet := make(map[string]bool, 0) + for _, label := range labels { + scope := label.ExclusiveScope() + if len(scope) > 0 { + scopeSet[scope] = true + } + } + + scopes := slices.Collect(maps.Keys(scopeSet)) + sort.Strings(scopes) + ctx.Data["ExclusiveLabelScopes"] = scopes +} + func renderMilestones(ctx *context.Context) { // Get milestones milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ @@ -778,6 +800,11 @@ func Issues(ctx *context.Context) { return } + renderExclusiveLabelScopes(ctx) + if ctx.Written() { + return + } + ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList) ctx.HTML(http.StatusOK, tplIssues) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 70019f3fa9..46128d7ce0 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -528,12 +528,13 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b // CreateLabelForm form for creating label type CreateLabelForm struct { - ID int64 - Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"` - Exclusive bool `form:"exclusive"` - IsArchived bool `form:"is_archived"` - Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"` - Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"` + ID int64 + Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"` + Exclusive bool `form:"exclusive"` + ExclusiveOrder int `form:"exclusive_order"` + IsArchived bool `form:"is_archived"` + Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"` + Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"` } // Validate validates the fields diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl index 7612d93b21..680df67456 100644 --- a/templates/repo/issue/filter_list.tmpl +++ b/templates/repo/issue/filter_list.tmpl @@ -133,5 +133,12 @@ {{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}} {{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}} {{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}} +
+
{{ctx.Locale.Tr "repo.issues.filter_label"}}
+ {{range .ExclusiveLabelScopes}} + {{$scope := .}} + {{$sortType := (printf "scope-%s" $scope)}} + {{$scope}} + {{end}} diff --git a/templates/repo/issue/labels/label_edit_modal.tmpl b/templates/repo/issue/labels/label_edit_modal.tmpl index 527b7ff900..7ee2cc7252 100644 --- a/templates/repo/issue/labels/label_edit_modal.tmpl +++ b/templates/repo/issue/labels/label_edit_modal.tmpl @@ -25,6 +25,14 @@ {{svg "octicon-alert"}} {{ctx.Locale.Tr "repo.issues.label_exclusive_warning"}}
+
+ + +
diff --git a/templates/repo/issue/labels/label_list.tmpl b/templates/repo/issue/labels/label_list.tmpl index 822567301e..a7c8016427 100644 --- a/templates/repo/issue/labels/label_list.tmpl +++ b/templates/repo/issue/labels/label_list.tmpl @@ -50,6 +50,7 @@ data-label-id="{{.ID}}" data-label-name="{{.Name}}" data-label-color="{{.Color}}" data-label-exclusive="{{.Exclusive}}" data-label-is-archived="{{gt .ArchivedUnix 0}}" data-label-num-issues="{{.NumIssues}}" data-label-description="{{.Description}}" + data-label-exclusive-order="{{.ExclusiveOrder}}" >{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.label_edit"}} ('.label-exclusive-input'); const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning'); + const elExclusiveOrderField = elModal.querySelector('.label-exclusive-order-input-field'); + const elExclusiveOrderInput = elModal.querySelector('.label-exclusive-order-input'); const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field'); const elIsArchivedInput = elModal.querySelector('.label-is-archived-input'); const elDescInput = elModal.querySelector('.label-desc-input'); @@ -29,6 +31,7 @@ export function initCompLabelEdit(pageSelector: string) { const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive'); toggleElem(elExclusiveWarning, showExclusiveWarning); if (!hasScope) elExclusiveInput.checked = false; + toggleElem(elExclusiveOrderField, elExclusiveInput.checked); }; const showLabelEditModal = (btn:HTMLElement) => { @@ -36,6 +39,7 @@ export function initCompLabelEdit(pageSelector: string) { const form = elModal.querySelector('form'); elLabelId.value = btn.getAttribute('data-label-id') || ''; elNameInput.value = btn.getAttribute('data-label-name') || ''; + elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0'; elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true'; elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true'; elDescInput.value = btn.getAttribute('data-label-description') || '';