diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index a919317728..25dbdebfa4 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -11,7 +11,6 @@ import ( indexer_internal "gitea.dev/modules/indexer/internal" inner_bleve "gitea.dev/modules/indexer/internal/bleve" "gitea.dev/modules/indexer/issues/internal" - "gitea.dev/modules/optional" "gitea.dev/modules/util" "github.com/blevesearch/bleve/v2" @@ -27,7 +26,7 @@ import ( const ( issueIndexerAnalyzer = "issueIndexer" issueIndexerDocType = "issueIndexerDocType" - issueIndexerLatestVersion = 6 + issueIndexerLatestVersion = 7 ) const unicodeNormalizeName = "unicodeNormalize" @@ -86,7 +85,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) { docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping) docMapping.AddFieldMappingsAt("no_project", boolFieldMapping) docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping) - docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("assignee_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("no_assignee", boolFieldMapping) docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping) docMapping.AddFieldMappingsAt("reviewed_ids", numberFieldMapping) docMapping.AddFieldMappingsAt("review_requested_ids", numberFieldMapping) @@ -258,14 +258,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( queries = append(queries, inner_bleve.NumericEqualityQuery(posterIDInt64, "poster_id")) } - if options.AssigneeID != "" { - if options.AssigneeID == "(any)" { - queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(optional.Some[int64](1), optional.None[int64](), "assignee_id")) - } else { - // "(none)" becomes 0, it means no assignee - assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64) - queries = append(queries, inner_bleve.NumericEqualityQuery(assigneeIDInt64, "assignee_id")) - } + switch options.AssigneeID { + case "": + case "(any)": + queries = append(queries, inner_bleve.BoolFieldQuery(false, "no_assignee")) + case "(none)": + queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_assignee")) + default: + assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64) + queries = append(queries, inner_bleve.NumericEqualityQuery(assigneeIDInt64, "assignee_ids")) } if options.MentionID.Has() { diff --git a/modules/indexer/issues/bleve/bleve_test.go b/modules/indexer/issues/bleve/bleve_test.go index ccd7ab7b92..7dc664e0ef 100644 --- a/modules/indexer/issues/bleve/bleve_test.go +++ b/modules/indexer/issues/bleve/bleve_test.go @@ -6,7 +6,11 @@ package bleve import ( "testing" + "gitea.dev/modules/indexer/issues/internal" "gitea.dev/modules/indexer/issues/internal/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestBleveIndexer(t *testing.T) { @@ -16,3 +20,67 @@ func TestBleveIndexer(t *testing.T) { tests.TestIndexer(t, indexer) } + +func TestBleveIndexerNoAssignee(t *testing.T) { + dir := t.TempDir() + indexer := NewIndexer(dir) + defer indexer.Close() + + _, err := indexer.Init(t.Context()) + require.NoError(t, err) + + require.NoError(t, indexer.Index(t.Context(), + &internal.IndexerData{ID: 1, Title: "assigned through assignee_ids", AssigneeIDs: []int64{2}}, + &internal.IndexerData{ID: 2, Title: "unassigned", NoAssignee: true}, + &internal.IndexerData{ID: 3, Title: "assigned through multiple assignee_ids", AssigneeIDs: []int64{3, 4}}, + )) + + testCases := []struct { + name string + opts *internal.SearchOptions + expectedIDs []int64 + }{ + { + name: "none", + opts: &internal.SearchOptions{AssigneeID: "(none)"}, + expectedIDs: []int64{2}, + }, + { + name: "any", + opts: &internal.SearchOptions{AssigneeID: "(any)"}, + expectedIDs: []int64{1, 3}, + }, + { + name: "specific", + opts: &internal.SearchOptions{AssigneeID: "2"}, + expectedIDs: []int64{1}, + }, + { + name: "specific first multi-assignee", + opts: &internal.SearchOptions{AssigneeID: "3"}, + expectedIDs: []int64{3}, + }, + { + name: "specific second multi-assignee", + opts: &internal.SearchOptions{AssigneeID: "4"}, + expectedIDs: []int64{3}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + result, err := indexer.Search(t.Context(), testCase.opts) + require.NoError(t, err) + assert.Equal(t, int64(len(testCase.expectedIDs)), result.Total) + assert.ElementsMatch(t, testCase.expectedIDs, searchResultIDs(result)) + }) + } +} + +func searchResultIDs(result *internal.SearchResult) []int64 { + ids := make([]int64, 0, len(result.Hits)) + for _, hit := range result.Hits { + ids = append(ids, hit.ID) + } + return ids +} diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index baef644cc1..87670a428d 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -16,7 +16,7 @@ import ( "gitea.dev/modules/util" ) -const issueIndexerLatestVersion = 3 +const issueIndexerLatestVersion = 4 var _ internal.Indexer = &Indexer{} @@ -57,7 +57,8 @@ const ( "project_ids": { "type": "integer", "index": true }, "no_project": { "type": "boolean", "index": true }, "poster_id": { "type": "integer", "index": true }, - "assignee_id": { "type": "integer", "index": true }, + "assignee_ids": { "type": "integer", "index": true }, + "no_assignee": { "type": "boolean", "index": true }, "mention_ids": { "type": "integer", "index": true }, "reviewed_ids": { "type": "integer", "index": true }, "review_requested_ids": { "type": "integer", "index": true }, @@ -177,14 +178,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.Must(es.TermQuery("poster_id", posterIDInt64)) } - if options.AssigneeID != "" { - if options.AssigneeID == "(any)" { - query.Must(es.NewRangeQuery("assignee_id").Gte(1)) - } else { - // "(none)" becomes 0, it means no assignee - assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64) - query.Must(es.TermQuery("assignee_id", assigneeIDInt64)) - } + switch options.AssigneeID { + case "": + case "(any)": + query.Must(es.TermQuery("no_assignee", false)) + case "(none)": + query.Must(es.TermQuery("no_assignee", true)) + default: + assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64) + query.Must(es.TermQuery("assignee_ids", assigneeIDInt64)) } if options.MentionID.Has() { diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index b964f349df..0e9162f0b9 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -34,7 +34,8 @@ type IndexerData struct { NoProject bool `json:"no_project"` // True if ProjectIDs is empty ProjectColumnMap map[int64]int64 `json:"project_column_map,omitempty"` // Maps project ID to column ID for each project the issue is in PosterID int64 `json:"poster_id"` - AssigneeID int64 `json:"assignee_id"` + AssigneeIDs []int64 `json:"assignee_ids"` + NoAssignee bool `json:"no_assignee"` // True if the issue has no assignees MentionIDs []int64 `json:"mention_ids"` ReviewedIDs []int64 `json:"reviewed_ids"` ReviewRequestedIDs []int64 `json:"review_requested_ids"` diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 276f3fb5bb..8337272592 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -377,10 +377,10 @@ var cases = []*testIndexerCase{ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { assert.Len(t, result.Hits, 5) for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].AssigneeID) + assert.True(t, slices.Contains(data[v.ID].AssigneeIDs, int64(1))) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.AssigneeID == 1 + return slices.Contains(v.AssigneeIDs, int64(1)) }), result.Total) }, }, @@ -395,10 +395,10 @@ var cases = []*testIndexerCase{ Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { assert.Len(t, result.Hits, 5) for _, v := range result.Hits { - assert.Equal(t, int64(0), data[v.ID].AssigneeID) + assert.True(t, data[v.ID].NoAssignee) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.AssigneeID == 0 + return v.NoAssignee }), result.Total) }, }, @@ -630,10 +630,10 @@ var cases = []*testIndexerCase{ 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.False(t, data[v.ID].NoAssignee) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.AssigneeID >= 1 + return !v.NoAssignee }), result.Total) }, }, @@ -686,6 +686,18 @@ func generateDefaultIndexerData() []*internal.IndexerData { for i := range projectIDs { projectIDs[i] = int64(i) + 1 // projectID should not be 0 } + var assigneeIDs []int64 + if issueIndex%10 != 0 { + assigneeID := issueIndex % 10 + assigneeIDs = []int64{assigneeID} + if issueIndex%3 == 0 { + nextAssigneeID := assigneeID + 1 + if nextAssigneeID == 10 { + nextAssigneeID = 1 + } + assigneeIDs = append(assigneeIDs, nextAssigneeID) + } + } data = append(data, &internal.IndexerData{ ID: id, @@ -702,7 +714,8 @@ func generateDefaultIndexerData() []*internal.IndexerData { ProjectIDs: projectIDs, NoProject: len(projectIDs) == 0, PosterID: id%10 + 1, // PosterID should not be 0 - AssigneeID: issueIndex % 10, + AssigneeIDs: assigneeIDs, + NoAssignee: len(assigneeIDs) == 0, MentionIDs: mentionIDs, ReviewedIDs: reviewedIDs, ReviewRequestedIDs: reviewRequestedIDs, diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index ddde9c89f0..1ec55bc7e2 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -20,7 +20,7 @@ import ( ) const ( - issueIndexerLatestVersion = 5 + issueIndexerLatestVersion = 6 // TODO: make this configurable if necessary maxTotalHits = 10000 @@ -74,7 +74,8 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer { "project_ids", "no_project", "poster_id", - "assignee_id", + "assignee_ids", + "no_assignee", "mention_ids", "reviewed_ids", "review_requested_ids", @@ -195,14 +196,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.And(inner_meilisearch.NewFilterEq("poster_id", posterIDInt64)) } - if options.AssigneeID != "" { - if options.AssigneeID == "(any)" { - query.And(inner_meilisearch.NewFilterGte("assignee_id", 1)) - } else { - // "(none)" becomes 0, it means no assignee - assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64) - query.And(inner_meilisearch.NewFilterEq("assignee_id", assigneeIDInt64)) - } + switch options.AssigneeID { + case "": + case "(any)": + query.And(inner_meilisearch.NewFilterEq("no_assignee", false)) + case "(none)": + query.And(inner_meilisearch.NewFilterEq("no_assignee", true)) + default: + assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64) + query.And(inner_meilisearch.NewFilterEq("assignee_ids", assigneeIDInt64)) } if options.MentionID.Has() { diff --git a/modules/indexer/issues/util.go b/modules/indexer/issues/util.go index bbfd42be6b..993356c0b4 100644 --- a/modules/indexer/issues/util.go +++ b/modules/indexer/issues/util.go @@ -87,6 +87,11 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD return nil, false, err } + assigneeIDs := make([]int64, 0, len(issue.Assignees)) + for _, a := range issue.Assignees { + assigneeIDs = append(assigneeIDs, a.ID) + } + projectIDs := make([]int64, 0, len(issue.Projects)) for _, project := range issue.Projects { projectIDs = append(projectIDs, project.ID) @@ -112,7 +117,8 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD ProjectIDs: projectIDs, NoProject: len(projectIDs) == 0, PosterID: issue.PosterID, - AssigneeID: issue.AssigneeID, + AssigneeIDs: assigneeIDs, + NoAssignee: len(assigneeIDs) == 0, MentionIDs: mentionIDs, ReviewedIDs: reviewedIDs, ReviewRequestedIDs: reviewRequestedIDs,