From cf7050b91dba8c1502b9ba420dc5a04a7b09cae6 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Wed, 21 Oct 2026 22:40:31 -0500 Subject: [PATCH] fix(issues): keep one row per issue when sorting by exclusive label order (#39364) Sorting by an exclusive label scope joined `issue_label`, returning one row per label. Issues with several labels were listed repeatedly and pushed others off the page, and `IssueIDs` returned inflated totals. The sort key now comes from a correlated subquery, so each issue yields one row. Fixes: https://github.com/go-gitea/gitea/issues/35711 Co-authored-by: silverwind --- models/issues/issue_search.go | 10 +++++----- tests/integration/issue_test.go | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 7fedab54b41..3c79f92e159 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -75,11 +75,11 @@ func applySorts(sess db.Session, sortType string, priorityRepoID int64) { // Since this sortType is dynamically created, it has to be treated specially. if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok { scope := after - sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id") - // "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null - sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%") - // Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int) - sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id") + // a subquery instead of a JOIN keeps one row per issue, "exclusive_order=0" means unset and COALESCE sorts it last on every DB + sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+ + " INNER JOIN label ON label.id = issue_label.label_id"+ + " WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%"). + Desc("issue.id") return } diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index 95bd8564ff6..0117a032b54 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -124,6 +124,27 @@ func TestViewIssuesSortByType(t *testing.T) { }) } +func TestIssueIDsSortByLabelScope(t *testing.T) { + assert.NoError(t, unittest.LoadFixtures()) + high := &issues_model.Label{RepoID: 1, Name: "priority/high", Exclusive: true, ExclusiveOrder: 1} + low := &issues_model.Label{RepoID: 1, Name: "priority/low", Exclusive: true, ExclusiveOrder: 2} + assert.NoError(t, db.Insert(t.Context(), high, low)) + assert.NoError(t, db.Insert(t.Context(), + &issues_model.IssueLabel{IssueID: 1, LabelID: low.ID}, + &issues_model.IssueLabel{IssueID: 2, LabelID: high.ID}, + )) + for page, expectedIDs := range [][]int64{{2, 1, 11}, {5, 3}} { + issueIDs, total, err := issues_model.IssueIDs(t.Context(), &issues_model.IssuesOptions{ + RepoIDs: []int64{1}, + SortType: issues_model.ScopeSortPrefix + "priority", + Paginator: &db.ListOptions{Page: page + 1, PageSize: 3}, + }) + assert.NoError(t, err) + assert.Equal(t, expectedIDs, issueIDs) + assert.EqualValues(t, 5, total) + } +} + func TestViewIssuesKeyword(t *testing.T) { defer tests.PrepareTestEnv(t)()