Skip to content

Commit 0da7318

Browse files
Allow filtering issues by any assignee (#33343)
This is the opposite of the "No assignee" filter, it will match all issues that have at least one assignee. Before ![Before change](https://github.com/user-attachments/assets/4aea194b-9add-4a84-8d6b-61bfd8d9e58e) After ![After change with any filter](https://github.com/user-attachments/assets/99f1205d-ba9f-4a0a-a60b-cc1a0c0823fe) --------- Co-authored-by: wxiaoguang <[email protected]>
1 parent a4df01b commit 0da7318

File tree

22 files changed

+169
-103
lines changed

22 files changed

+169
-103
lines changed

models/db/search.go

-4
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,3 @@ const (
2929
// NoConditionID means a condition to filter the records which don't match any id.
3030
// eg: "milestone_id=-1" means "find the items without any milestone.
3131
const NoConditionID int64 = -1
32-
33-
// NonExistingID means a condition to match no result (eg: a non-existing user)
34-
// It doesn't use -1 or -2 because they are used as builtin users.
35-
const NonExistingID int64 = -1000000

models/issues/issue_search.go

+15-16
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ type IssuesOptions struct { //nolint
2727
RepoIDs []int64 // overwrites RepoCond if the length is not 0
2828
AllPublic bool // include also all public repositories
2929
RepoCond builder.Cond
30-
AssigneeID optional.Option[int64]
31-
PosterID optional.Option[int64]
30+
AssigneeID string // "(none)" or "(any)" or a user ID
31+
PosterID string // "(none)" or "(any)" or a user ID
3232
MentionedID int64
3333
ReviewRequestedID int64
3434
ReviewedID int64
@@ -356,26 +356,25 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_mod
356356
return cond
357357
}
358358

359-
func applyAssigneeCondition(sess *xorm.Session, assigneeID optional.Option[int64]) {
359+
func applyAssigneeCondition(sess *xorm.Session, assigneeID string) {
360360
// old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
361-
if !assigneeID.Has() || assigneeID.Value() == 0 {
362-
return
363-
}
364-
if assigneeID.Value() == db.NoConditionID {
361+
if assigneeID == "(none)" {
365362
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
366-
} else {
363+
} else if assigneeID == "(any)" {
364+
sess.Where("issue.id IN (SELECT issue_id FROM issue_assignees)")
365+
} else if assigneeIDInt64, _ := strconv.ParseInt(assigneeID, 10, 64); assigneeIDInt64 > 0 {
367366
sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
368-
And("issue_assignees.assignee_id = ?", assigneeID.Value())
367+
And("issue_assignees.assignee_id = ?", assigneeIDInt64)
369368
}
370369
}
371370

372-
func applyPosterCondition(sess *xorm.Session, posterID optional.Option[int64]) {
373-
if !posterID.Has() {
374-
return
375-
}
376-
// poster doesn't need to support db.NoConditionID(-1), so just use the value as-is
377-
if posterID.Has() {
378-
sess.And("issue.poster_id=?", posterID.Value())
371+
func applyPosterCondition(sess *xorm.Session, posterID string) {
372+
// Actually every issue has a poster.
373+
// The "(none)" is for internal usage only: when doer tries to search non-existing user as poster, use "(none)" to return empty result.
374+
if posterID == "(none)" {
375+
sess.And("issue.poster_id=0")
376+
} else if posterIDInt64, _ := strconv.ParseInt(posterID, 10, 64); posterIDInt64 > 0 {
377+
sess.And("issue.poster_id=?", posterIDInt64)
379378
}
380379
}
381380

models/issues/issue_test.go

+1-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
repo_model "code.gitea.io/gitea/models/repo"
1616
"code.gitea.io/gitea/models/unittest"
1717
user_model "code.gitea.io/gitea/models/user"
18-
"code.gitea.io/gitea/modules/optional"
1918
"code.gitea.io/gitea/modules/setting"
2019

2120
"github.com/stretchr/testify/assert"
@@ -155,7 +154,7 @@ func TestIssues(t *testing.T) {
155154
}{
156155
{
157156
issues_model.IssuesOptions{
158-
AssigneeID: optional.Some(int64(1)),
157+
AssigneeID: "1",
159158
SortType: "oldest",
160159
},
161160
[]int64{1, 6},

modules/indexer/issues/bleve/bleve.go

+14-4
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ package bleve
55

66
import (
77
"context"
8+
"strconv"
89

910
"code.gitea.io/gitea/modules/indexer"
1011
indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
1112
inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve"
1213
"code.gitea.io/gitea/modules/indexer/issues/internal"
14+
"code.gitea.io/gitea/modules/optional"
1315
"code.gitea.io/gitea/modules/util"
1416

1517
"github.com/blevesearch/bleve/v2"
@@ -246,12 +248,20 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
246248
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
247249
}
248250

249-
if options.PosterID.Has() {
250-
queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id"))
251+
if options.PosterID != "" {
252+
// "(none)" becomes 0, it means no poster
253+
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
254+
queries = append(queries, inner_bleve.NumericEqualityQuery(posterIDInt64, "poster_id"))
251255
}
252256

253-
if options.AssigneeID.Has() {
254-
queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id"))
257+
if options.AssigneeID != "" {
258+
if options.AssigneeID == "(any)" {
259+
queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(optional.Some[int64](1), optional.None[int64](), "assignee_id"))
260+
} else {
261+
// "(none)" becomes 0, it means no assignee
262+
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
263+
queries = append(queries, inner_bleve.NumericEqualityQuery(assigneeIDInt64, "assignee_id"))
264+
}
255265
}
256266

257267
if options.MentionID.Has() {

modules/indexer/issues/db/options.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
5454
RepoIDs: options.RepoIDs,
5555
AllPublic: options.AllPublic,
5656
RepoCond: nil,
57-
AssigneeID: optional.Some(convertID(options.AssigneeID)),
57+
AssigneeID: options.AssigneeID,
5858
PosterID: options.PosterID,
5959
MentionedID: convertID(options.MentionID),
6060
ReviewRequestedID: convertID(options.ReviewRequestedID),

modules/indexer/issues/dboptions.go

+1-5
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
4545
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
4646
}
4747

48-
if opts.AssigneeID.Value() == db.NoConditionID {
49-
searchOpt.AssigneeID = optional.Some[int64](0) // FIXME: this is inconsistent from other places, 0 means "no assignee"
50-
} else if opts.AssigneeID.Value() != 0 {
51-
searchOpt.AssigneeID = opts.AssigneeID
52-
}
48+
searchOpt.AssigneeID = opts.AssigneeID
5349

5450
// See the comment of issues_model.SearchOptions for the reason why we need to convert
5551
convertID := func(id int64) optional.Option[int64] {

modules/indexer/issues/elasticsearch/elasticsearch.go

+14-4
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,22 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
212212
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
213213
}
214214

215-
if options.PosterID.Has() {
216-
query.Must(elastic.NewTermQuery("poster_id", options.PosterID.Value()))
215+
if options.PosterID != "" {
216+
// "(none)" becomes 0, it means no poster
217+
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
218+
query.Must(elastic.NewTermQuery("poster_id", posterIDInt64))
217219
}
218220

219-
if options.AssigneeID.Has() {
220-
query.Must(elastic.NewTermQuery("assignee_id", options.AssigneeID.Value()))
221+
if options.AssigneeID != "" {
222+
if options.AssigneeID == "(any)" {
223+
q := elastic.NewRangeQuery("assignee_id")
224+
q.Gte(1)
225+
query.Must(q)
226+
} else {
227+
// "(none)" becomes 0, it means no assignee
228+
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
229+
query.Must(elastic.NewTermQuery("assignee_id", assigneeIDInt64))
230+
}
221231
}
222232

223233
if options.MentionID.Has() {

modules/indexer/issues/indexer_test.go

+27-4
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func TestDBSearchIssues(t *testing.T) {
4444
t.Run("search issues with order", searchIssueWithOrder)
4545
t.Run("search issues in project", searchIssueInProject)
4646
t.Run("search issues with paginator", searchIssueWithPaginator)
47+
t.Run("search issues with any assignee", searchIssueWithAnyAssignee)
4748
}
4849

4950
func searchIssueWithKeyword(t *testing.T) {
@@ -176,19 +177,19 @@ func searchIssueByID(t *testing.T) {
176177
}{
177178
{
178179
opts: SearchOptions{
179-
PosterID: optional.Some(int64(1)),
180+
PosterID: "1",
180181
},
181182
expectedIDs: []int64{11, 6, 3, 2, 1},
182183
},
183184
{
184185
opts: SearchOptions{
185-
AssigneeID: optional.Some(int64(1)),
186+
AssigneeID: "1",
186187
},
187188
expectedIDs: []int64{6, 1},
188189
},
189190
{
190-
// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1.
191-
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: optional.Some(db.NoConditionID)}),
191+
// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it handles the filter correctly
192+
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: "(none)"}),
192193
expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2},
193194
},
194195
{
@@ -462,3 +463,25 @@ func searchIssueWithPaginator(t *testing.T) {
462463
assert.Equal(t, test.expectedTotal, total)
463464
}
464465
}
466+
467+
func searchIssueWithAnyAssignee(t *testing.T) {
468+
tests := []struct {
469+
opts SearchOptions
470+
expectedIDs []int64
471+
expectedTotal int64
472+
}{
473+
{
474+
SearchOptions{
475+
AssigneeID: "(any)",
476+
},
477+
[]int64{17, 6, 1},
478+
3,
479+
},
480+
}
481+
for _, test := range tests {
482+
issueIDs, total, err := SearchIssues(t.Context(), &test.opts)
483+
require.NoError(t, err)
484+
assert.Equal(t, test.expectedIDs, issueIDs)
485+
assert.Equal(t, test.expectedTotal, total)
486+
}
487+
}

modules/indexer/issues/internal/model.go

+2-3
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,8 @@ type SearchOptions struct {
9797
ProjectID optional.Option[int64] // project the issues belong to
9898
ProjectColumnID optional.Option[int64] // project column the issues belong to
9999

100-
PosterID optional.Option[int64] // poster of the issues
101-
102-
AssigneeID optional.Option[int64] // assignee of the issues, zero means no assignee
100+
PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
101+
AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID
103102

104103
MentionID optional.Option[int64] // mentioned user of the issues
105104

modules/indexer/issues/internal/tests/tests.go

+18-3
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ var cases = []*testIndexerCase{
379379
Paginator: &db.ListOptions{
380380
PageSize: 5,
381381
},
382-
PosterID: optional.Some(int64(1)),
382+
PosterID: "1",
383383
},
384384
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
385385
assert.Len(t, result.Hits, 5)
@@ -397,7 +397,7 @@ var cases = []*testIndexerCase{
397397
Paginator: &db.ListOptions{
398398
PageSize: 5,
399399
},
400-
AssigneeID: optional.Some(int64(1)),
400+
AssigneeID: "1",
401401
},
402402
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
403403
assert.Len(t, result.Hits, 5)
@@ -415,7 +415,7 @@ var cases = []*testIndexerCase{
415415
Paginator: &db.ListOptions{
416416
PageSize: 5,
417417
},
418-
AssigneeID: optional.Some(int64(0)),
418+
AssigneeID: "(none)",
419419
},
420420
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
421421
assert.Len(t, result.Hits, 5)
@@ -647,6 +647,21 @@ var cases = []*testIndexerCase{
647647
}
648648
},
649649
},
650+
{
651+
Name: "SearchAnyAssignee",
652+
SearchOptions: &internal.SearchOptions{
653+
AssigneeID: "(any)",
654+
},
655+
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
656+
assert.Len(t, result.Hits, 180)
657+
for _, v := range result.Hits {
658+
assert.GreaterOrEqual(t, data[v.ID].AssigneeID, int64(1))
659+
}
660+
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
661+
return v.AssigneeID >= 1
662+
}), result.Total)
663+
},
664+
},
650665
}
651666

652667
type testIndexerCase struct {

modules/indexer/issues/meilisearch/meilisearch.go

+14-6
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,20 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
187187
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
188188
}
189189

190-
if options.PosterID.Has() {
191-
query.And(inner_meilisearch.NewFilterEq("poster_id", options.PosterID.Value()))
192-
}
193-
194-
if options.AssigneeID.Has() {
195-
query.And(inner_meilisearch.NewFilterEq("assignee_id", options.AssigneeID.Value()))
190+
if options.PosterID != "" {
191+
// "(none)" becomes 0, it means no poster
192+
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
193+
query.And(inner_meilisearch.NewFilterEq("poster_id", posterIDInt64))
194+
}
195+
196+
if options.AssigneeID != "" {
197+
if options.AssigneeID == "(any)" {
198+
query.And(inner_meilisearch.NewFilterGte("assignee_id", 1))
199+
} else {
200+
// "(none)" becomes 0, it means no assignee
201+
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
202+
query.And(inner_meilisearch.NewFilterEq("assignee_id", assigneeIDInt64))
203+
}
196204
}
197205

198206
if options.MentionID.Has() {

options/locale/locale_en-US.ini

+2-2
Original file line numberDiff line numberDiff line change
@@ -1547,8 +1547,8 @@ issues.filter_project = Project
15471547
issues.filter_project_all = All projects
15481548
issues.filter_project_none = No project
15491549
issues.filter_assignee = Assignee
1550-
issues.filter_assginee_no_select = All assignees
1551-
issues.filter_assginee_no_assignee = No assignee
1550+
issues.filter_assginee_no_assignee = Assigned to nobody
1551+
issues.filter_assignee_any_assignee = Assigned to anybody
15521552
issues.filter_poster = Author
15531553
issues.filter_user_placeholder = Search users
15541554
issues.filter_user_no_select = All users

routers/api/v1/repo/issue.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,10 @@ func SearchIssues(ctx *context.APIContext) {
290290
if ctx.IsSigned {
291291
ctxUserID := ctx.Doer.ID
292292
if ctx.FormBool("created") {
293-
searchOpt.PosterID = optional.Some(ctxUserID)
293+
searchOpt.PosterID = strconv.FormatInt(ctxUserID, 10)
294294
}
295295
if ctx.FormBool("assigned") {
296-
searchOpt.AssigneeID = optional.Some(ctxUserID)
296+
searchOpt.AssigneeID = strconv.FormatInt(ctxUserID, 10)
297297
}
298298
if ctx.FormBool("mentioned") {
299299
searchOpt.MentionID = optional.Some(ctxUserID)
@@ -538,10 +538,10 @@ func ListIssues(ctx *context.APIContext) {
538538
}
539539

540540
if createdByID > 0 {
541-
searchOpt.PosterID = optional.Some(createdByID)
541+
searchOpt.PosterID = strconv.FormatInt(createdByID, 10)
542542
}
543543
if assignedByID > 0 {
544-
searchOpt.AssigneeID = optional.Some(assignedByID)
544+
searchOpt.AssigneeID = strconv.FormatInt(assignedByID, 10)
545545
}
546546
if mentionedByID > 0 {
547547
searchOpt.MentionID = optional.Some(mentionedByID)

routers/web/org/projects.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -347,11 +347,11 @@ func ViewProject(ctx *context.Context) {
347347
if ctx.Written() {
348348
return
349349
}
350-
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
350+
assigneeID := ctx.FormString("assignee")
351351

352352
opts := issues_model.IssuesOptions{
353353
LabelIDs: labelIDs,
354-
AssigneeID: optional.Some(assigneeID),
354+
AssigneeID: assigneeID,
355355
Owner: project.Owner,
356356
Doer: ctx.Doer,
357357
}

0 commit comments

Comments
 (0)