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 <team@tsinbei.com>
This commit is contained in:
Giteabot
2026-08-17 12:23:37 -07:00
committed by GitHub
parent 1c3ae57f35
commit 11de54dd41
4 changed files with 83 additions and 0 deletions
+38
View File
@@ -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)
+41
View File
@@ -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{
+2
View File
@@ -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...)
+2
View File
@@ -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...)
}