Skip to content

Commit fa49cd7

Browse files
telackeywxiaoguang
andauthored
feat: Add sorting by exclusive labels (issue priority) (#33206)
Fix #2616 This PR adds a new sort option for exclusive labels. For exclusive labels, a new property is exposed called "order", while in the UI options are populated automatically in the `Sort` column (see screenshot below) for each exclusive label scope. --------- Co-authored-by: wxiaoguang <[email protected]>
1 parent 02e49a0 commit fa49cd7

File tree

28 files changed

+236
-105
lines changed

28 files changed

+236
-105
lines changed

models/issues/issue_search.go

+13
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
"xorm.io/xorm"
2222
)
2323

24+
const ScopeSortPrefix = "scope-"
25+
2426
// IssuesOptions represents options of an issue.
2527
type IssuesOptions struct { //nolint
2628
Paginator *db.ListOptions
@@ -70,6 +72,17 @@ func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOption
7072
// applySorts sort an issues-related session based on the provided
7173
// sortType string
7274
func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
75+
// Since this sortType is dynamically created, it has to be treated specially.
76+
if strings.HasPrefix(sortType, ScopeSortPrefix) {
77+
scope := strings.TrimPrefix(sortType, ScopeSortPrefix)
78+
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
79+
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
80+
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
81+
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
82+
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
83+
return
84+
}
85+
7386
switch sortType {
7487
case "oldest":
7588
sess.Asc("issue.created_unix").Asc("issue.id")

models/issues/label.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type Label struct {
8787
OrgID int64 `xorm:"INDEX"`
8888
Name string
8989
Exclusive bool
90+
ExclusiveOrder int `xorm:"DEFAULT 0"` // 0 means no exclusive order
9091
Description string
9192
Color string `xorm:"VARCHAR(7)"`
9293
NumIssues int
@@ -236,7 +237,7 @@ func UpdateLabel(ctx context.Context, l *Label) error {
236237
}
237238
l.Color = color
238239

239-
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "archived_unix")
240+
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
240241
}
241242

242243
// DeleteLabel delete a label

models/migrations/migrations.go

+1
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ func prepareMigrationTasks() []*migration {
380380
newMigration(316, "Add description for secrets and variables", v1_24.AddDescriptionForSecretsAndVariables),
381381
newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard),
382382
newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode),
383+
newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable),
383384
}
384385
return preparedMigrations
385386
}

models/migrations/v1_24/v319.go

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_24 //nolint
5+
6+
import (
7+
"xorm.io/xorm"
8+
)
9+
10+
func AddExclusiveOrderColumnToLabelTable(x *xorm.Engine) error {
11+
type Label struct {
12+
ExclusiveOrder int `xorm:"DEFAULT 0"`
13+
}
14+
15+
return x.Sync(new(Label))
16+
}

modules/indexer/issues/db/db.go

+10-7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package db
66
import (
77
"context"
88
"strings"
9+
"sync"
910

1011
"code.gitea.io/gitea/models/db"
1112
issue_model "code.gitea.io/gitea/models/issues"
@@ -18,7 +19,7 @@ import (
1819
"xorm.io/builder"
1920
)
2021

21-
var _ internal.Indexer = &Indexer{}
22+
var _ internal.Indexer = (*Indexer)(nil)
2223

2324
// Indexer implements Indexer interface to use database's like search
2425
type Indexer struct {
@@ -29,11 +30,9 @@ func (i *Indexer) SupportedSearchModes() []indexer.SearchMode {
2930
return indexer.SearchModesExactWords()
3031
}
3132

32-
func NewIndexer() *Indexer {
33-
return &Indexer{
34-
Indexer: &inner_db.Indexer{},
35-
}
36-
}
33+
var GetIndexer = sync.OnceValue(func() *Indexer {
34+
return &Indexer{Indexer: &inner_db.Indexer{}}
35+
})
3736

3837
// Index dummy function
3938
func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error {
@@ -122,7 +121,11 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
122121
}, nil
123122
}
124123

125-
ids, total, err := issue_model.IssueIDs(ctx, opt, cond)
124+
return i.FindWithIssueOptions(ctx, opt, cond)
125+
}
126+
127+
func (i *Indexer) FindWithIssueOptions(ctx context.Context, opt *issue_model.IssuesOptions, otherConds ...builder.Cond) (*internal.SearchResult, error) {
128+
ids, total, err := issue_model.IssueIDs(ctx, opt, otherConds...)
126129
if err != nil {
127130
return nil, err
128131
}

modules/indexer/issues/db/options.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package db
66
import (
77
"context"
88
"fmt"
9+
"strings"
910

1011
"code.gitea.io/gitea/models/db"
1112
issue_model "code.gitea.io/gitea/models/issues"
@@ -34,7 +35,11 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
3435
case internal.SortByDeadlineAsc:
3536
sortType = "nearduedate"
3637
default:
37-
sortType = "newest"
38+
if strings.HasPrefix(string(options.SortBy), issue_model.ScopeSortPrefix) {
39+
sortType = string(options.SortBy)
40+
} else {
41+
sortType = "newest"
42+
}
3843
}
3944

4045
// See the comment of issues_model.SearchOptions for the reason why we need to convert
@@ -68,7 +73,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
6873
ExcludedLabelNames: nil,
6974
IncludeMilestones: nil,
7075
SortType: sortType,
71-
IssueIDs: nil,
7276
UpdatedAfterUnix: options.UpdatedAfterUnix.Value(),
7377
UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(),
7478
PriorityRepoID: 0,

modules/indexer/issues/dboptions.go

+12-1
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44
package issues
55

66
import (
7+
"strings"
8+
79
"code.gitea.io/gitea/models/db"
810
issues_model "code.gitea.io/gitea/models/issues"
11+
"code.gitea.io/gitea/modules/indexer/issues/internal"
912
"code.gitea.io/gitea/modules/optional"
13+
"code.gitea.io/gitea/modules/setting"
1014
)
1115

1216
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
17+
if opts.IssueIDs != nil {
18+
setting.PanicInDevOrTesting("Indexer SearchOptions doesn't support IssueIDs")
19+
}
1320
searchOpt := &SearchOptions{
1421
Keyword: keyword,
1522
RepoIDs: opts.RepoIDs,
@@ -95,7 +102,11 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
95102
// Unsupported sort type for search
96103
fallthrough
97104
default:
98-
searchOpt.SortBy = SortByUpdatedDesc
105+
if strings.HasPrefix(opts.SortType, issues_model.ScopeSortPrefix) {
106+
searchOpt.SortBy = internal.SortBy(opts.SortType)
107+
} else {
108+
searchOpt.SortBy = SortByUpdatedDesc
109+
}
99110
}
100111

101112
return searchOpt

modules/indexer/issues/indexer.go

+7-4
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func InitIssueIndexer(syncReindex bool) {
103103
log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
104104
}
105105
case "db":
106-
issueIndexer = db.NewIndexer()
106+
issueIndexer = db.GetIndexer()
107107
case "meilisearch":
108108
issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
109109
existed, err = issueIndexer.Init(ctx)
@@ -291,19 +291,22 @@ func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, err
291291
// So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue.
292292
// Even worse, the external indexer like elastic search may not be available for a while,
293293
// and the user may not be able to list issues completely until it is available again.
294-
ix = db.NewIndexer()
294+
ix = db.GetIndexer()
295295
}
296+
296297
result, err := ix.Search(ctx, opts)
297298
if err != nil {
298299
return nil, 0, err
299300
}
301+
return SearchResultToIDSlice(result), result.Total, nil
302+
}
300303

304+
func SearchResultToIDSlice(result *internal.SearchResult) []int64 {
301305
ret := make([]int64, 0, len(result.Hits))
302306
for _, hit := range result.Hits {
303307
ret = append(ret, hit.ID)
304308
}
305-
306-
return ret, result.Total, nil
309+
return ret
307310
}
308311

309312
// CountIssues counts issues by options. It is a shortcut of SearchIssues(ctx, opts) but only returns the total count.

modules/label/label.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ var colorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
1414

1515
// Label represents label information loaded from template
1616
type Label struct {
17-
Name string `yaml:"name"`
18-
Color string `yaml:"color"`
19-
Description string `yaml:"description,omitempty"`
20-
Exclusive bool `yaml:"exclusive,omitempty"`
17+
Name string `yaml:"name"`
18+
Color string `yaml:"color"`
19+
Description string `yaml:"description,omitempty"`
20+
Exclusive bool `yaml:"exclusive,omitempty"`
21+
ExclusiveOrder int `yaml:"exclusive_order,omitempty"`
2122
}
2223

2324
// NormalizeColor normalizes a color string to a 6-character hex code

modules/repository/init.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,11 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg
127127
labels := make([]*issues_model.Label, len(list))
128128
for i := 0; i < len(list); i++ {
129129
labels[i] = &issues_model.Label{
130-
Name: list[i].Name,
131-
Exclusive: list[i].Exclusive,
132-
Description: list[i].Description,
133-
Color: list[i].Color,
130+
Name: list[i].Name,
131+
Exclusive: list[i].Exclusive,
132+
ExclusiveOrder: list[i].ExclusiveOrder,
133+
Description: list[i].Description,
134+
Color: list[i].Color,
134135
}
135136
if isOrg {
136137
labels[i].OrgID = id

modules/templates/util_render.go

+16-1
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,28 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
170170
itemColor := "#" + hex.EncodeToString(itemBytes)
171171
scopeColor := "#" + hex.EncodeToString(scopeBytes)
172172

173+
if label.ExclusiveOrder > 0 {
174+
// <scope> | <label> | <order>
175+
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
176+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
177+
`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+
178+
`<div class="ui label scope-right">%d</div>`+
179+
`</span>`,
180+
extraCSSClasses, descriptionText,
181+
textColor, scopeColor, scopeHTML,
182+
textColor, itemColor, itemHTML,
183+
label.ExclusiveOrder)
184+
}
185+
186+
// <scope> | <label>
173187
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
174188
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
175189
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
176190
`</span>`,
177191
extraCSSClasses, descriptionText,
178192
textColor, scopeColor, scopeHTML,
179-
textColor, itemColor, itemHTML)
193+
textColor, itemColor, itemHTML,
194+
)
180195
}
181196

182197
// RenderEmoji renders html text with emoji post processors

options/label/Advanced.yaml

+11
Original file line numberDiff line numberDiff line change
@@ -22,49 +22,60 @@ labels:
2222
description: Breaking change that won't be backward compatible
2323
- name: "Reviewed/Duplicate"
2424
exclusive: true
25+
exclusive_order: 2
2526
color: 616161
2627
description: This issue or pull request already exists
2728
- name: "Reviewed/Invalid"
2829
exclusive: true
30+
exclusive_order: 3
2931
color: 546e7a
3032
description: Invalid issue
3133
- name: "Reviewed/Confirmed"
3234
exclusive: true
35+
exclusive_order: 1
3336
color: 795548
3437
description: Issue has been confirmed
3538
- name: "Reviewed/Won't Fix"
3639
exclusive: true
40+
exclusive_order: 3
3741
color: eeeeee
3842
description: This issue won't be fixed
3943
- name: "Status/Need More Info"
4044
exclusive: true
45+
exclusive_order: 2
4146
color: 424242
4247
description: Feedback is required to reproduce issue or to continue work
4348
- name: "Status/Blocked"
4449
exclusive: true
50+
exclusive_order: 1
4551
color: 880e4f
4652
description: Something is blocking this issue or pull request
4753
- name: "Status/Abandoned"
4854
exclusive: true
55+
exclusive_order: 3
4956
color: "222222"
5057
description: Somebody has started to work on this but abandoned work
5158
- name: "Priority/Critical"
5259
exclusive: true
60+
exclusive_order: 1
5361
color: b71c1c
5462
description: The priority is critical
5563
priority: critical
5664
- name: "Priority/High"
5765
exclusive: true
66+
exclusive_order: 2
5867
color: d32f2f
5968
description: The priority is high
6069
priority: high
6170
- name: "Priority/Medium"
6271
exclusive: true
72+
exclusive_order: 3
6373
color: e64a19
6474
description: The priority is medium
6575
priority: medium
6676
- name: "Priority/Low"
6777
exclusive: true
78+
exclusive_order: 4
6879
color: 4caf50
6980
description: The priority is low
7081
priority: low

options/locale/locale_en-US.ini

+2
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,8 @@ issues.label_archived_filter = Show archived labels
16551655
issues.label_archive_tooltip = Archived labels are excluded by default from the suggestions when searching by label.
16561656
issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels.
16571657
issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request.
1658+
issues.label_exclusive_order = Sort Order
1659+
issues.label_exclusive_order_tooltip = Exclusive labels in the same scope will be sorted according to this numeric order.
16581660
issues.label_count = %d labels
16591661
issues.label_open_issues = %d open issues/pull requests
16601662
issues.label_edit = Edit

routers/web/org/org_labels.go

+7-5
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,12 @@ func NewLabel(ctx *context.Context) {
4444
}
4545

4646
l := &issues_model.Label{
47-
OrgID: ctx.Org.Organization.ID,
48-
Name: form.Title,
49-
Exclusive: form.Exclusive,
50-
Description: form.Description,
51-
Color: form.Color,
47+
OrgID: ctx.Org.Organization.ID,
48+
Name: form.Title,
49+
Exclusive: form.Exclusive,
50+
Description: form.Description,
51+
Color: form.Color,
52+
ExclusiveOrder: form.ExclusiveOrder,
5253
}
5354
if err := issues_model.NewLabel(ctx, l); err != nil {
5455
ctx.ServerError("NewLabel", err)
@@ -73,6 +74,7 @@ func UpdateLabel(ctx *context.Context) {
7374

7475
l.Name = form.Title
7576
l.Exclusive = form.Exclusive
77+
l.ExclusiveOrder = form.ExclusiveOrder
7678
l.Description = form.Description
7779
l.Color = form.Color
7880
l.SetArchived(form.IsArchived)

0 commit comments

Comments
 (0)