diff --git a/models/db/search.go b/models/db/search.go index e0a1b6bde9ffd..6e82be6709154 100644 --- a/models/db/search.go +++ b/models/db/search.go @@ -30,6 +30,11 @@ const ( // eg: "milestone_id=-1" means "find the items without any milestone. const NoConditionID int64 = -1 +// AnyConditionID means a condition to filter the records which match any id. +// The inverse of the above NoConditionID +// eg: "assignee_id=-1000001" means "find the issues with an assignee" +const AnyConditionID int64 = -1000001 + // NonExistingID means a condition to match no result (eg: a non-existing user) // It doesn't use -1 or -2 because they are used as builtin users. const NonExistingID int64 = -1000000 diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 694b918755dda..d788527f89837 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -363,6 +363,8 @@ func applyAssigneeCondition(sess *xorm.Session, assigneeID optional.Option[int64 } if assigneeID.Value() == db.NoConditionID { sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)") + } else if assigneeID.Value() == db.AnyConditionID { + sess.Where("issue.id IN (SELECT issue_id FROM issue_assignees)") } else { sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id"). And("issue_assignees.assignee_id = ?", assigneeID.Value()) diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index bf51bd6c14857..8d2c4f34952e6 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -9,6 +9,7 @@ import ( indexer_internal "code.gitea.io/gitea/modules/indexer/internal" inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve" "code.gitea.io/gitea/modules/indexer/issues/internal" + "code.gitea.io/gitea/modules/optional" "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" @@ -236,7 +237,9 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id")) } - if options.AssigneeID.Has() { + if options.AnyAssigneeOnly { + queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(optional.Some[int64](1), optional.None[int64](), "assignee_id")) + } else if options.AssigneeID.Has() { queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id")) } diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go index 87ce398a202d0..ac9f034ed5ce2 100644 --- a/modules/indexer/issues/db/options.go +++ b/modules/indexer/issues/db/options.go @@ -49,12 +49,17 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m return value } + assigneeID := optional.Some(convertID(options.AssigneeID)) + if options.AnyAssigneeOnly { + assigneeID = optional.Some(db.AnyConditionID) + } + opts := &issue_model.IssuesOptions{ Paginator: options.Paginator, RepoIDs: options.RepoIDs, AllPublic: options.AllPublic, RepoCond: nil, - AssigneeID: optional.Some(convertID(options.AssigneeID)), + AssigneeID: assigneeID, PosterID: options.PosterID, MentionedID: convertID(options.MentionID), ReviewRequestedID: convertID(options.ReviewRequestedID), diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index 4f6ad96d222d7..701e18af67329 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -47,6 +47,8 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp if opts.AssigneeID.Value() == db.NoConditionID { searchOpt.AssigneeID = optional.Some[int64](0) // FIXME: this is inconsistent from other places, 0 means "no assignee" + } else if opts.AssigneeID.Value() == db.AnyConditionID { + searchOpt.AnyAssigneeOnly = true } else if opts.AssigneeID.Value() != 0 { searchOpt.AssigneeID = opts.AssigneeID } diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index 4c293f3f2a9c8..69f5e18c5f7ce 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -209,7 +209,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.Must(elastic.NewTermQuery("poster_id", options.PosterID.Value())) } - if options.AssigneeID.Has() { + if options.AnyAssigneeOnly { + q := elastic.NewRangeQuery("assignee_id") + q.Gte(1) + query.Must(q) + } else if options.AssigneeID.Has() { query.Must(elastic.NewTermQuery("assignee_id", options.AssigneeID.Value())) } diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 7def2a2c6e5b9..e1dbd793b5a57 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -44,6 +44,7 @@ func TestDBSearchIssues(t *testing.T) { t.Run("search issues with order", searchIssueWithOrder) t.Run("search issues in project", searchIssueInProject) t.Run("search issues with paginator", searchIssueWithPaginator) + t.Run("search issues with any assignee", searchIssueWithAnyAssignee) } func searchIssueWithKeyword(t *testing.T) { @@ -460,3 +461,25 @@ func searchIssueWithPaginator(t *testing.T) { assert.Equal(t, test.expectedTotal, total) } } + +func searchIssueWithAnyAssignee(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + expectedTotal int64 + }{ + { + SearchOptions{ + AnyAssigneeOnly: true, + }, + []int64{17, 6, 1}, + 3, + }, + } + for _, test := range tests { + issueIDs, total, err := SearchIssues(t.Context(), &test.opts) + require.NoError(t, err) + assert.Equal(t, test.expectedIDs, issueIDs) + assert.Equal(t, test.expectedTotal, total) + } +} diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index 09dcbf4804c88..1c9e3531cd917 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -98,7 +98,8 @@ type SearchOptions struct { PosterID optional.Option[int64] // poster of the issues - AssigneeID optional.Option[int64] // assignee of the issues, zero means no assignee + AssigneeID optional.Option[int64] // assignee of the issues, zero means no assignee + AnyAssigneeOnly bool // if the issues have any assignee (non-zero), if true, AssigneeID will be ignored MentionID optional.Option[int64] // mentioned user of the issues diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 0483853dfd19e..af23ec4e46fef 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -647,6 +647,21 @@ var cases = []*testIndexerCase{ } }, }, + { + Name: "SearchAnyAssignee", + SearchOptions: &internal.SearchOptions{ + AnyAssigneeOnly: true, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Len(t, result.Hits, 180) + for _, v := range result.Hits { + assert.GreaterOrEqual(t, data[v.ID].AssigneeID, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.AssigneeID >= 1 + }), result.Total) + }, + }, } type testIndexerCase struct { diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index 1066e96272575..b0c88933cbaf5 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -186,7 +186,9 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.And(inner_meilisearch.NewFilterEq("poster_id", options.PosterID.Value())) } - if options.AssigneeID.Has() { + if options.AnyAssigneeOnly { + query.And(inner_meilisearch.NewFilterGte("assignee_id", 1)) + } else if options.AssigneeID.Has() { query.And(inner_meilisearch.NewFilterEq("assignee_id", options.AssigneeID.Value())) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c2c5b07b653af..02680c1fac16e 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1542,6 +1542,7 @@ issues.filter_project_none = No project issues.filter_assignee = Assignee issues.filter_assginee_no_select = All assignees issues.filter_assginee_no_assignee = No assignee +issues.filter_assignee_any_assignee = Any assignee issues.filter_poster = Author issues.filter_user_placeholder = Search users issues.filter_user_no_select = All users diff --git a/templates/repo/issue/filter_item_user_assign.tmpl b/templates/repo/issue/filter_item_user_assign.tmpl index 4f1db71d57f0a..028a0bfca5a93 100644 --- a/templates/repo/issue/filter_item_user_assign.tmpl +++ b/templates/repo/issue/filter_item_user_assign.tmpl @@ -6,6 +6,7 @@ * TextFilterTitle * TextZeroValue: the text for "all issues" * TextNegativeOne: the text for "issues with no assignee" +* TextAnyCondition: the text for "issues with any assignee" */}} {{$queryLink := .QueryLink}}