fix(issues): sort scoped labels by exclusive order in dropdowns (#38893)

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.
This commit is contained in:
Hsukqi Lee
2026-08-18 02:47:28 +08:00
committed by GitHub
parent 7857c5f843
commit 551a6bb3a4
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{