From 11de54dd415efb409f71a048df49a66f4c327605 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Mon, 17 Aug 2026 12:23:37 -0700 Subject: [PATCH] fix(issues): sort scoped labels by exclusive order in dropdowns (#38893) (#38954) Backport #38893 by @HsukqiLee Closes #38872 Labels in the label selection dropdown (issue/PR sidebar, new issue form) were always listed alphabetically, so a scoped set like the default Priority labels showed up as Critical, High, Low, Medium even though each label carries an exclusive order. This adds `CompareLabelForDisplay`/`SortLabelsForDisplay` in `models/issues`: labels are grouped by their exclusive scope and sorted by exclusive order within a scope (unordered ones last), falling back to name order. The sorting is applied to the issue page sidebar data and the shared label filter data, so the filter dropdown on the issue list gets the same ordering. Unscoped labels are unaffected and still sort by name. Includes a unit test covering the default Priority label set. Co-authored-by: Hsukqi Lee --- models/issues/label.go | 38 +++++++++++++++++++++++ models/issues/label_test.go | 41 +++++++++++++++++++++++++ routers/web/repo/issue_page_meta.go | 2 ++ routers/web/shared/issue/issue_label.go | 2 ++ 4 files changed, 83 insertions(+) diff --git a/models/issues/label.go b/models/issues/label.go index 5b9898b3b27..0c3010601ad 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -5,9 +5,11 @@ package issues import ( + "cmp" "context" "errors" "fmt" + "math" "slices" "strconv" "strings" @@ -191,6 +193,42 @@ func (l *Label) ExclusiveScope() string { return l.Name[:lastIndex] } +// CompareLabelForDisplay compares labels for displaying them in dropdowns or lists. +// Labels are grouped by their exclusive scope, and labels within the same scope +// are sorted by their exclusive order, where unordered labels (order 0) come last. +// Labels without a scope are listed first and everything else falls back to name order. +func CompareLabelForDisplay(a, b *Label) int { + scopeA, scopeB := a.ExclusiveScope(), b.ExclusiveScope() + if scopeA != scopeB { + if scopeA == "" { + return -1 + } + if scopeB == "" { + return 1 + } + return strings.Compare(scopeA, scopeB) + } + if scopeA != "" { + orderA, orderB := a.ExclusiveOrder, b.ExclusiveOrder + if orderA <= 0 { + orderA = math.MaxInt + } + if orderB <= 0 { + orderB = math.MaxInt + } + if orderA != orderB { + return cmp.Compare(orderA, orderB) + } + } + return strings.Compare(a.Name, b.Name) +} + +// SortLabelsForDisplay sorts labels in place for displaying them in dropdowns or lists, +// grouping them by their exclusive scope and respecting the exclusive order within each scope. +func SortLabelsForDisplay(labels []*Label) { + slices.SortStableFunc(labels, CompareLabelForDisplay) +} + // NewLabel creates a new label func NewLabel(ctx context.Context, l *Label) error { color, err := label.NormalizeColor(l.Color) diff --git a/models/issues/label_test.go b/models/issues/label_test.go index ea70e1cc214..94958e414fc 100644 --- a/models/issues/label_test.go +++ b/models/issues/label_test.go @@ -54,6 +54,47 @@ func TestLabel_ExclusiveScope(t *testing.T) { assert.Equal(t, "scope/subscope", label.ExclusiveScope()) } +func TestSortLabelsForDisplay(t *testing.T) { + labels := []*issues_model.Label{ + {Name: "priority/low", Exclusive: true, ExclusiveOrder: 4}, + {Name: "priority/critical", Exclusive: true, ExclusiveOrder: 1}, + {Name: "priority/medium", Exclusive: true, ExclusiveOrder: 3}, + {Name: "priority/high", Exclusive: true, ExclusiveOrder: 2}, + {Name: "bug"}, + {Name: "enhancement"}, + {Name: "kind/question", Exclusive: true}, + } + issues_model.SortLabelsForDisplay(labels) + + names := make([]string, 0, len(labels)) + for _, l := range labels { + names = append(names, l.Name) + } + assert.Equal(t, []string{ + "bug", + "enhancement", + "kind/question", + "priority/critical", + "priority/high", + "priority/medium", + "priority/low", + }, names) + + // labels without an exclusive order in the same scope are listed last, ordered by name + labels = []*issues_model.Label{ + {Name: "scope/unordered-b", Exclusive: true}, + {Name: "scope/ordered", Exclusive: true, ExclusiveOrder: 1}, + {Name: "scope/unordered-a", Exclusive: true}, + } + issues_model.SortLabelsForDisplay(labels) + + names = names[:0] + for _, l := range labels { + names = append(names, l.Name) + } + assert.Equal(t, []string{"scope/ordered", "scope/unordered-a", "scope/unordered-b"}, names) +} + func TestNewLabels(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) labels := []*issues_model.Label{ diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 3ef8826ce7b..0bc52993482 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -516,6 +516,7 @@ func (d *IssuePageMetaData) retrieveLabelsData(ctx *context.Context) { ctx.ServerError("GetLabelsByRepoID", err) return } + issues_model.SortLabelsForDisplay(labels) labelsData.RepoLabels = labels if repo.Owner.IsOrganization() { @@ -523,6 +524,7 @@ func (d *IssuePageMetaData) retrieveLabelsData(ctx *context.Context) { if err != nil { return } + issues_model.SortLabelsForDisplay(orgLabels) labelsData.OrgLabels = orgLabels } labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...) diff --git a/routers/web/shared/issue/issue_label.go b/routers/web/shared/issue/issue_label.go index d8ebc987df1..aba0c2b1efe 100644 --- a/routers/web/shared/issue/issue_label.go +++ b/routers/web/shared/issue/issue_label.go @@ -38,6 +38,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo ctx.ServerError("GetLabelsByRepoID", err) return ret } + issues_model.SortLabelsForDisplay(repoLabels) allLabels = append(allLabels, repoLabels...) } @@ -47,6 +48,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo ctx.ServerError("GetLabelsByOrgID", err) return ret } + issues_model.SortLabelsForDisplay(orgLabels) allLabels = append(allLabels, orgLabels...) }