feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
@@ -7,16 +7,14 @@ import (
"context"
"strconv"
"code.gitea.io/gitea/modules/indexer"
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"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/indexer"
indexer_internal "gitea.dev/modules/indexer/internal"
inner_bleve "gitea.dev/modules/indexer/internal/bleve"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/util"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
@@ -27,7 +25,7 @@ import (
const (
issueIndexerAnalyzer = "issueIndexer"
issueIndexerDocType = "issueIndexerDocType"
issueIndexerLatestVersion = 5
issueIndexerLatestVersion = 8
)
const unicodeNormalizeName = "unicodeNormalize"
@@ -83,10 +81,11 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)
docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)
docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping)
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)
@@ -103,7 +102,7 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
"token_filters": []string{unicodeNormalizeName, camelCaseKeepWholeName, lowercase.Name},
}); err != nil {
return nil, err
}
@@ -241,11 +240,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
}
if options.ProjectID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
}
if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
if options.NoProjectOnly {
queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_project"))
} else if len(options.ProjectIDs) > 0 {
var projectQueries []query.Query
for _, projectID := range options.ProjectIDs {
projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_ids"))
}
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...))
}
if options.PosterID != "" {
@@ -254,14 +257,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() {
@@ -6,7 +6,11 @@ package bleve
import (
"testing"
"code.gitea.io/gitea/modules/indexer/issues/internal/tests"
"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,108 @@ 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 TestBleveIndexerTokenFilter(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: "fix(packages): SomeThing needs a rewrite (#12345)"},
&internal.IndexerData{ID: 2, Title: "add support for mDNS discovery abc1234"},
))
testCases := []struct {
name string
keyword string
expectedIDs []int64
}{
{name: "exact original case", keyword: "SomeThing", expectedIDs: []int64{1}},
{name: "case matching original transitions", keyword: "someThing", expectedIDs: []int64{1}},
{name: "all lower case", keyword: "something", expectedIDs: []int64{1}},
{name: "all upper case", keyword: "SOMETHING", expectedIDs: []int64{1}},
{name: "number match", keyword: "12345", expectedIDs: []int64{1}},
{name: "number as part", keyword: "1234", expectedIDs: []int64{2}},
{name: "sub-word search still works", keyword: "DNS", expectedIDs: []int64{2}},
{name: "sub-word search, lower case", keyword: "mdns", expectedIDs: []int64{2}},
{name: "keyword is camel case", keyword: "addSupport", expectedIDs: []int64{2}},
{name: "keyword not match", keyword: "addsupport", expectedIDs: []int64{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
result, err := indexer.Search(t.Context(), &internal.SearchOptions{
Keyword: testCase.keyword,
})
require.NoError(t, err)
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
}
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package bleve
import (
"gitea.dev/modules/util"
"github.com/blevesearch/bleve/v2/analysis"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
"github.com/blevesearch/bleve/v2/registry"
)
const camelCaseKeepWholeName = "camelCaseKeepWhole"
// camelCaseKeepWholeFilter behaves like bleve's built-in "camelCase" token filter,
// it also uses the whole word for a token. For example: when indexing "someThing",
// CamelCaseFilter only emits "some" and "thing", this filter also emits "something".
// It is questionable why the "issue indexer" used the CamelCaseFilter, it just can't search "someThing".
// To avoid breaking existing user experiences, this "whole token filter" is introduced to make the full word can be searched.
type camelCaseKeepWholeFilter struct {
inner *camelcase.CamelCaseFilter
}
func (f *camelCaseKeepWholeFilter) Filter(input analysis.TokenStream) analysis.TokenStream {
// First, do exactly what the stock camelCase filter does: split by "camelCase" tokens
split := f.inner.Filter(input)
// Index the resulting position of the *first* sub-token produced for
// each original token (matched by start offset), so the duplicated
// whole-word token we add below lines up at the same position as the
// sub-word it stands in for, instead of drifting out of sync for
// fields with more than one original token.
posByStart := make(map[int]int, len(split))
for _, tok := range split {
if _, ok := posByStart[tok.Start]; !ok {
posByStart[tok.Start] = tok.Position
}
}
rv := make(analysis.TokenStream, 0, len(split)+len(input))
rv = append(rv, split...)
// Then append one extra, un-split copy of every original token, so the
// whole word survives as a standalone, independently searchable term.
for _, token := range input {
dup := *token
dup.Term = append([]byte(nil), token.Term...)
if pos, ok := posByStart[token.Start]; ok {
dup.Position = pos
}
rv = append(rv, &dup)
}
return rv
}
func camelCaseKeepWholeFilterConstructor(_ map[string]any, _ *registry.Cache) (analysis.TokenFilter, error) {
return &camelCaseKeepWholeFilter{inner: camelcase.NewCamelCaseFilter()}, nil
}
func init() {
util.MustNoError(registry.RegisterTokenFilter(camelCaseKeepWholeName, camelCaseKeepWholeFilterConstructor))
}
@@ -8,13 +8,13 @@ import (
"strings"
"sync"
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_db "code.gitea.io/gitea/modules/indexer/internal/db"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/util"
"gitea.dev/models/db"
issue_model "gitea.dev/models/issues"
"gitea.dev/modules/indexer"
indexer_internal "gitea.dev/modules/indexer/internal"
inner_db "gitea.dev/modules/indexer/internal/db"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/util"
"xorm.io/builder"
)
@@ -8,11 +8,12 @@ import (
"fmt"
"strings"
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"gitea.dev/models/db"
issue_model "gitea.dev/models/issues"
"gitea.dev/modules/container"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
)
func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) {
@@ -65,8 +66,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
ReviewRequestedID: convertID(options.ReviewRequestedID),
ReviewedID: convertID(options.ReviewedID),
SubscriberID: convertID(options.SubscriberID),
ProjectID: convertID(options.ProjectID),
ProjectColumnID: convertID(options.ProjectColumnID),
ProjectIDs: util.Iif(options.NoProjectOnly, []int64{db.NoConditionID}, options.ProjectIDs),
IsClosed: options.IsClosed,
IsPull: options.IsPull,
IncludedLabelNames: nil,
@@ -6,11 +6,11 @@ package issues
import (
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
)
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
@@ -46,10 +46,10 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.MilestoneIDs = opts.MilestoneIDs
}
if opts.ProjectID > 0 {
searchOpt.ProjectID = optional.Some(opts.ProjectID)
} else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID {
searchOpt.NoProjectOnly = true
} else {
searchOpt.ProjectIDs = opts.ProjectIDs
}
searchOpt.AssigneeID = opts.AssigneeID
@@ -65,7 +65,6 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
return nil
}
searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
searchOpt.PosterID = opts.PosterID
searchOpt.MentionID = convertID(opts.MentionedID)
searchOpt.ReviewedID = convertID(opts.ReviewedID)
@@ -8,30 +8,21 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/util"
"github.com/olivere/elastic/v7"
"gitea.dev/modules/graceful"
"gitea.dev/modules/indexer"
indexer_internal "gitea.dev/modules/indexer/internal"
es "gitea.dev/modules/indexer/internal/elasticsearch"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/util"
)
const (
issueIndexerLatestVersion = 2
// multi-match-types, currently only 2 types are used
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
esMultiMatchTypeBestFields = "best_fields"
esMultiMatchTypePhrasePrefix = "phrase_prefix"
)
const issueIndexerLatestVersion = 4
var _ internal.Indexer = &Indexer{}
// Indexer implements Indexer interface
type Indexer struct {
inner *inner_elasticsearch.Indexer
indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much
*es.Indexer
}
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
@@ -41,12 +32,7 @@ func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
// NewIndexer creates a new elasticsearch indexer
func NewIndexer(url, indexerName string) *Indexer {
inner := inner_elasticsearch.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)
indexer := &Indexer{
inner: inner,
Indexer: inner,
}
return indexer
return &Indexer{Indexer: es.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)}
}
const (
@@ -68,10 +54,11 @@ const (
"label_ids": { "type": "integer", "index": true },
"no_label": { "type": "boolean", "index": true },
"milestone_id": { "type": "integer", "index": true },
"project_id": { "type": "integer", "index": true },
"project_board_id": { "type": "integer", "index": true },
"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 },
@@ -93,29 +80,14 @@ func (b *Indexer) Index(ctx context.Context, issues ...*internal.IndexerData) er
return nil
} else if len(issues) == 1 {
issue := issues[0]
_, err := b.inner.Client.Index().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(issue.ID, 10)).
BodyJson(issue).
Do(ctx)
return err
return b.Indexer.Index(ctx, strconv.FormatInt(issue.ID, 10), issue)
}
reqs := make([]elastic.BulkableRequest, 0)
ops := make([]es.BulkOp, 0, len(issues))
for _, issue := range issues {
reqs = append(reqs,
elastic.NewBulkIndexRequest().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(issue.ID, 10)).
Doc(issue),
)
ops = append(ops, es.IndexOp(strconv.FormatInt(issue.ID, 10), issue))
}
_, err := b.inner.Client.Bulk().
Index(b.inner.VersionedIndexName()).
Add(reqs...).
Do(graceful.GetManager().HammerContext())
return err
return b.Bulk(graceful.GetManager().HammerContext(), ops)
}
// Delete deletes indexes by ids
@@ -123,129 +95,117 @@ func (b *Indexer) Delete(ctx context.Context, ids ...int64) error {
if len(ids) == 0 {
return nil
} else if len(ids) == 1 {
_, err := b.inner.Client.Delete().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(ids[0], 10)).
Do(ctx)
return err
return b.Indexer.Delete(ctx, strconv.FormatInt(ids[0], 10))
}
reqs := make([]elastic.BulkableRequest, 0)
ops := make([]es.BulkOp, 0, len(ids))
for _, id := range ids {
reqs = append(reqs,
elastic.NewBulkDeleteRequest().
Index(b.inner.VersionedIndexName()).
Id(strconv.FormatInt(id, 10)),
)
ops = append(ops, es.DeleteOp(strconv.FormatInt(id, 10)))
}
_, err := b.inner.Client.Bulk().
Index(b.inner.VersionedIndexName()).
Add(reqs...).
Do(graceful.GetManager().HammerContext())
return err
return b.Bulk(graceful.GetManager().HammerContext(), ops)
}
// Search searches for issues by given conditions.
// Returns the matching issue IDs
func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
query := elastic.NewBoolQuery()
query := es.NewBoolQuery()
if options.Keyword != "" {
searchMode := util.IfZero(options.SearchMode, b.SupportedSearchModes()[0].ModeValue)
mm := es.NewMultiMatchQuery(options.Keyword, "title", "content", "comments")
if searchMode == indexer.SearchModeExact {
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypePhrasePrefix))
} else /* words */ {
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypeBestFields).Operator("and"))
mm = mm.Type(es.MultiMatchTypePhrasePrefix)
} else {
mm = mm.Type(es.MultiMatchTypeBestFields).Operator("and")
}
query.Must(mm)
}
if len(options.RepoIDs) > 0 {
q := elastic.NewBoolQuery()
q.Should(elastic.NewTermsQuery("repo_id", toAnySlice(options.RepoIDs)...))
q := es.NewBoolQuery()
q.Should(es.TermsQuery("repo_id", es.ToAnySlice(options.RepoIDs)...))
if options.AllPublic {
q.Should(elastic.NewTermQuery("is_public", true))
q.Should(es.TermQuery("is_public", true))
}
query.Must(q)
}
if options.IsPull.Has() {
query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value()))
query.Must(es.TermQuery("is_pull", options.IsPull.Value()))
}
if options.IsClosed.Has() {
query.Must(elastic.NewTermQuery("is_closed", options.IsClosed.Value()))
query.Must(es.TermQuery("is_closed", options.IsClosed.Value()))
}
if options.IsArchived.Has() {
query.Must(elastic.NewTermQuery("is_archived", options.IsArchived.Value()))
query.Must(es.TermQuery("is_archived", options.IsArchived.Value()))
}
if options.NoLabelOnly {
query.Must(elastic.NewTermQuery("no_label", true))
query.Must(es.TermQuery("no_label", true))
} else {
if len(options.IncludedLabelIDs) > 0 {
q := elastic.NewBoolQuery()
q := es.NewBoolQuery()
for _, labelID := range options.IncludedLabelIDs {
q.Must(elastic.NewTermQuery("label_ids", labelID))
q.Must(es.TermQuery("label_ids", labelID))
}
query.Must(q)
} else if len(options.IncludedAnyLabelIDs) > 0 {
query.Must(elastic.NewTermsQuery("label_ids", toAnySlice(options.IncludedAnyLabelIDs)...))
query.Must(es.TermsQuery("label_ids", es.ToAnySlice(options.IncludedAnyLabelIDs)...))
}
if len(options.ExcludedLabelIDs) > 0 {
q := elastic.NewBoolQuery()
q := es.NewBoolQuery()
for _, labelID := range options.ExcludedLabelIDs {
q.MustNot(elastic.NewTermQuery("label_ids", labelID))
q.MustNot(es.TermQuery("label_ids", labelID))
}
query.Must(q)
}
}
if len(options.MilestoneIDs) > 0 {
query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
query.Must(es.TermsQuery("milestone_id", es.ToAnySlice(options.MilestoneIDs)...))
}
if options.ProjectID.Has() {
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
}
if options.ProjectColumnID.Has() {
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
if options.NoProjectOnly {
query.Must(es.TermQuery("no_project", true))
} else if len(options.ProjectIDs) > 0 {
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
query.Must(es.TermsQuery("project_ids", es.ToAnySlice(options.ProjectIDs)...))
}
if options.PosterID != "" {
// "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
query.Must(elastic.NewTermQuery("poster_id", posterIDInt64))
query.Must(es.TermQuery("poster_id", posterIDInt64))
}
if options.AssigneeID != "" {
if options.AssigneeID == "(any)" {
q := elastic.NewRangeQuery("assignee_id")
q.Gte(1)
query.Must(q)
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
query.Must(elastic.NewTermQuery("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() {
query.Must(elastic.NewTermQuery("mention_ids", options.MentionID.Value()))
query.Must(es.TermQuery("mention_ids", options.MentionID.Value()))
}
if options.ReviewedID.Has() {
query.Must(elastic.NewTermQuery("reviewed_ids", options.ReviewedID.Value()))
query.Must(es.TermQuery("reviewed_ids", options.ReviewedID.Value()))
}
if options.ReviewRequestedID.Has() {
query.Must(elastic.NewTermQuery("review_requested_ids", options.ReviewRequestedID.Value()))
query.Must(es.TermQuery("review_requested_ids", options.ReviewRequestedID.Value()))
}
if options.SubscriberID.Has() {
query.Must(elastic.NewTermQuery("subscriber_ids", options.SubscriberID.Value()))
query.Must(es.TermQuery("subscriber_ids", options.SubscriberID.Value()))
}
if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() {
q := elastic.NewRangeQuery("updated_unix")
q := es.NewRangeQuery("updated_unix")
if options.UpdatedAfterUnix.Has() {
q.Gte(options.UpdatedAfterUnix.Value())
}
@@ -258,9 +218,9 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
if options.SortBy == "" {
options.SortBy = internal.SortByCreatedAsc
}
sortBy := []elastic.Sorter{
sortBy := []es.SortField{
parseSortBy(options.SortBy),
elastic.NewFieldSort("id").Desc(),
{Field: "id", Desc: true},
}
// See https://stackoverflow.com/questions/35206409/elasticsearch-2-1-result-window-is-too-large-index-max-result-window/35221900
@@ -268,43 +228,30 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
const maxPageSize = 10000
skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxPageSize)
searchResult, err := b.inner.Client.Search().
Index(b.inner.VersionedIndexName()).
Query(query).
SortBy(sortBy...).
From(skip).Size(limit).
Do(ctx)
resp, err := b.Indexer.Search(ctx, es.SearchRequest{
Query: query,
Sort: sortBy,
From: skip,
Size: limit,
TrackTotal: true,
})
if err != nil {
return nil, err
}
hits := make([]internal.Match, 0, limit)
for _, hit := range searchResult.Hits.Hits {
id, _ := strconv.ParseInt(hit.Id, 10, 64)
hits = append(hits, internal.Match{
ID: id,
})
hits := make([]internal.Match, 0, len(resp.Hits))
for _, hit := range resp.Hits {
id, _ := strconv.ParseInt(hit.ID, 10, 64)
hits = append(hits, internal.Match{ID: id})
}
return &internal.SearchResult{
Total: searchResult.TotalHits(),
Total: resp.Total,
Hits: hits,
}, nil
}
func toAnySlice[T any](s []T) []any {
ret := make([]any, 0, len(s))
for _, item := range s {
ret = append(ret, item)
}
return ret
}
func parseSortBy(sortBy internal.SortBy) elastic.Sorter {
field := strings.TrimPrefix(string(sortBy), "-")
ret := elastic.NewFieldSort(field)
if strings.HasPrefix(string(sortBy), "-") {
ret.Desc()
}
return ret
func parseSortBy(sortBy internal.SortBy) es.SortField {
field, desc := strings.CutPrefix(string(sortBy), "-")
return es.SortField{Field: field, Desc: desc}
}
@@ -6,30 +6,39 @@ package elasticsearch
import (
"fmt"
"net/http"
"os"
"net/url"
"testing"
"time"
"code.gitea.io/gitea/modules/indexer/issues/internal/tests"
"gitea.dev/modules/indexer/issues/internal/tests"
"gitea.dev/modules/test"
"github.com/stretchr/testify/require"
)
func TestElasticsearchIndexer(t *testing.T) {
// The elasticsearch instance started by pull-db-tests.yml > test-unit > services > elasticsearch
url := "http://elastic:changeme@elasticsearch:9200"
rawURL := test.ExternalServiceHTTP(t, "TEST_ELASTICSEARCH_URL", "http://elastic:changeme@elasticsearch:9200")
if os.Getenv("CI") == "" {
// Make it possible to run tests against a local elasticsearch instance
url = os.Getenv("TEST_ELASTICSEARCH_URL")
if url == "" {
t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI")
return
}
}
// Go's net/http does not auto-attach URL userinfo as Basic Auth, so extract
// it and set the header explicitly; otherwise auth-enforced clusters answer
// 401 and the probe never reports ready.
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
user := parsed.User
parsed.User = nil
probeURL := parsed.String()
require.Eventually(t, func() bool {
resp, err := http.Get(url)
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
if err != nil {
return false
}
if user != nil {
pass, _ := user.Password()
req.SetBasicAuth(user.Username(), pass)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
@@ -37,7 +46,7 @@ func TestElasticsearchIndexer(t *testing.T) {
return resp.StatusCode == http.StatusOK
}, time.Minute, time.Second, "Expected elasticsearch to be up")
indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix()))
indexer := NewIndexer(rawURL, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix()))
defer indexer.Close()
tests.TestIndexer(t, indexer)
@@ -11,21 +11,21 @@ import (
"sync/atomic"
"time"
db_model "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/issues/bleve"
"code.gitea.io/gitea/modules/indexer/issues/db"
"code.gitea.io/gitea/modules/indexer/issues/elasticsearch"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/indexer/issues/meilisearch"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
db_model "gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/graceful"
"gitea.dev/modules/indexer"
"gitea.dev/modules/indexer/issues/bleve"
"gitea.dev/modules/indexer/issues/db"
"gitea.dev/modules/indexer/issues/elasticsearch"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/indexer/issues/meilisearch"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/process"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// IndexerMetadata is used to send data to the queue, so it contains only the ids.
@@ -6,16 +6,16 @@ package issues
import (
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/models/db"
"gitea.dev/models/issues"
"gitea.dev/models/unittest"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
_ "code.gitea.io/gitea/models"
_ "code.gitea.io/gitea/models/actions"
_ "code.gitea.io/gitea/models/activities"
_ "gitea.dev/models"
_ "gitea.dev/models/actions"
_ "gitea.dev/models/activities"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -416,28 +416,42 @@ func searchIssueInProject(t *testing.T) {
}{
{
SearchOptions{
ProjectID: optional.Some(int64(1)),
ProjectIDs: []int64{1},
},
[]int64{5, 3, 2, 1},
},
{
SearchOptions{
ProjectColumnID: optional.Some(int64(1)),
},
[]int64{1},
},
{
SearchOptions{
ProjectColumnID: optional.Some(int64(0)), // issue with in default column
},
[]int64{2},
},
}
for _, test := range tests {
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
require.NoError(t, err)
assert.Equal(t, test.expectedIDs, issueIDs)
}
// Test filtering for issues with no project assigned using dynamic validation
t.Run("no project assigned", func(t *testing.T) {
issueIDs, total, err := SearchIssues(t.Context(), &SearchOptions{
ProjectIDs: []int64{db.NoConditionID},
})
require.NoError(t, err)
assert.NotEmpty(t, issueIDs)
assert.Equal(t, total, int64(len(issueIDs)))
// Verify each returned issue actually has no project
for _, issueID := range issueIDs {
issue, err := issues.GetIssueByID(t.Context(), issueID)
require.NoError(t, err)
err = issue.LoadProjects(t.Context())
require.NoError(t, err)
assert.Empty(t, issue.Projects, "Issue %d should have no projects", issueID)
}
// Count total issues with no project to verify we got them all
allIssues, err := issues.Issues(t.Context(), &issues.IssuesOptions{
ProjectIDs: []int64{db.NoConditionID},
})
require.NoError(t, err)
assert.Len(t, issueIDs, len(allIssues), "Should return all issues with no project")
})
}
func searchIssueWithPaginator(t *testing.T) {
@@ -7,8 +7,8 @@ import (
"context"
"errors"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/internal"
"gitea.dev/modules/indexer"
"gitea.dev/modules/indexer/internal"
)
// Indexer defines an interface to indexer issues contents
@@ -6,10 +6,10 @@ package internal
import (
"strconv"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/timeutil"
"gitea.dev/models/db"
"gitea.dev/modules/indexer"
"gitea.dev/modules/optional"
"gitea.dev/modules/timeutil"
)
// IndexerData data stored in the issue indexer
@@ -30,10 +30,12 @@ type IndexerData struct {
LabelIDs []int64 `json:"label_ids"`
NoLabel bool `json:"no_label"` // True if LabelIDs is empty
MilestoneID int64 `json:"milestone_id"`
ProjectID int64 `json:"project_id"`
ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
ProjectIDs []int64 `json:"project_ids"`
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"`
@@ -94,8 +96,8 @@ type SearchOptions struct {
MilestoneIDs []int64 // milestones the issues have
ProjectID optional.Option[int64] // project the issues belong to
ProjectColumnID optional.Option[int64] // project column the issues belong to
ProjectIDs []int64 // project the issues belong to. FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong.
NoProjectOnly bool // if the issues have no project, if true, ProjectIDs will be ignored
PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID
@@ -13,10 +13,10 @@ import (
"testing"
"time"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/timeutil"
"gitea.dev/models/db"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/optional"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -116,6 +116,16 @@ var cases = []*testIndexerCase{
assert.Equal(t, len(data), int(result.Total))
},
},
{
// Exercises the single-doc Index/Delete fast path in backends that have one (e.g. Elasticsearch).
Name: "single-doc index",
ExtraData: []*internal.IndexerData{
{ID: 999, Title: "solo-issue-marker"},
},
SearchOptions: &internal.SearchOptions{Keyword: "solo-issue-marker"},
ExpectedIDs: []int64{999},
ExpectedTotal: 1,
},
{
Name: "Keyword",
ExtraData: []*internal.IndexerData{
@@ -301,75 +311,41 @@ var cases = []*testIndexerCase{
},
},
{
Name: "ProjectID",
Name: "ProjectIDs",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectID: optional.Some(int64(1)),
ProjectIDs: []int64{1},
},
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].ProjectID)
assert.Contains(t, data[v.ID].ProjectIDs, int64(1))
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 1
return slices.Contains(v.ProjectIDs, int64(1))
}), result.Total)
},
},
{
Name: "no ProjectID",
Name: "no ProjectIDs (empty array)",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
PageSize: 50,
},
ProjectID: optional.Some(int64(0)),
NoProjectOnly: true,
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
// Verify only issues with no projects are returned
for _, v := range result.Hits {
assert.Equal(t, int64(0), data[v.ID].ProjectID)
assert.Empty(t, data[v.ID].ProjectIDs, "Issue %d should have no projects", v.ID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 0
}), result.Total)
},
},
{
Name: "ProjectColumnID",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectColumnID: optional.Some(int64(1)),
},
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].ProjectColumnID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectColumnID == 1
}), result.Total)
},
},
{
Name: "no ProjectColumnID",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectColumnID: optional.Some(int64(0)),
},
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].ProjectColumnID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectColumnID == 0
}), result.Total)
// Verify we got ALL issues with no projects
expectedCount := countIndexerData(data, func(v *internal.IndexerData) bool {
return len(v.ProjectIDs) == 0
})
assert.Equal(t, expectedCount, result.Total, "Should return all %d issues with no project", expectedCount)
},
},
{
@@ -401,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)
},
},
@@ -419,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)
},
},
@@ -654,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)
},
},
@@ -706,6 +682,22 @@ func generateDefaultIndexerData() []*internal.IndexerData {
for i := range subscriberIDs {
subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0
}
projectIDs := make([]int64, id%5)
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,
@@ -719,10 +711,11 @@ func generateDefaultIndexerData() []*internal.IndexerData {
LabelIDs: labelIDs,
NoLabel: len(labelIDs) == 0,
MilestoneID: issueIndex % 4,
ProjectID: issueIndex % 5,
ProjectColumnID: issueIndex % 6,
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,
@@ -10,17 +10,17 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_meilisearch "code.gitea.io/gitea/modules/indexer/internal/meilisearch"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/json"
"gitea.dev/modules/indexer"
indexer_internal "gitea.dev/modules/indexer/internal"
inner_meilisearch "gitea.dev/modules/indexer/internal/meilisearch"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/json"
"github.com/meilisearch/meilisearch-go"
)
const (
issueIndexerLatestVersion = 4
issueIndexerLatestVersion = 6
// TODO: make this configurable if necessary
maxTotalHits = 10000
@@ -71,10 +71,11 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer {
"label_ids",
"no_label",
"milestone_id",
"project_id",
"project_board_id",
"project_ids",
"no_project",
"poster_id",
"assignee_id",
"assignee_ids",
"no_assignee",
"mention_ids",
"reviewed_ids",
"review_requested_ids",
@@ -182,11 +183,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
}
if options.ProjectID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
}
if options.ProjectColumnID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
if options.NoProjectOnly {
query.And(inner_meilisearch.NewFilterEq("no_project", true))
} else if len(options.ProjectIDs) > 0 {
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
query.And(inner_meilisearch.NewFilterIn("project_ids", options.ProjectIDs...))
}
if options.PosterID != "" {
@@ -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() {
@@ -10,9 +10,10 @@ import (
"testing"
"time"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/indexer/issues/internal/tests"
"code.gitea.io/gitea/modules/json"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/indexer/issues/internal/tests"
"gitea.dev/modules/json"
"gitea.dev/modules/test"
"github.com/meilisearch/meilisearch-go"
"github.com/stretchr/testify/assert"
@@ -21,18 +22,8 @@ import (
func TestMeilisearchIndexer(t *testing.T) {
// The meilisearch instance started by pull-db-tests.yml > test-unit > services > meilisearch
url := "http://meilisearch:7700"
key := "" // auth has been disabled in test environment
if os.Getenv("CI") == "" {
// Make it possible to run tests against a local meilisearch instance
url = os.Getenv("TEST_MEILISEARCH_URL")
if url == "" {
t.Skip("TEST_MEILISEARCH_URL not set and not running in CI")
return
}
key = os.Getenv("TEST_MEILISEARCH_KEY")
}
url := test.ExternalServiceHTTP(t, "TEST_MEILISEARCH_URL", "http://meilisearch:7700")
key := os.Getenv("TEST_MEILISEARCH_KEY")
require.Eventually(t, func() bool {
resp, err := http.Get(url)
@@ -8,12 +8,12 @@ import (
"errors"
"fmt"
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
"gitea.dev/models/db"
issue_model "gitea.dev/models/issues"
"gitea.dev/modules/container"
"gitea.dev/modules/indexer/issues/internal"
"gitea.dev/modules/log"
"gitea.dev/modules/queue"
)
// getIssueIndexerData returns the indexer data of an issue and a bool value indicating whether the issue exists.
@@ -87,14 +87,14 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
return nil, false, err
}
var projectID int64
if issue.Project != nil {
projectID = issue.Project.ID
assigneeIDs := make([]int64, 0, len(issue.Assignees))
for _, a := range issue.Assignees {
assigneeIDs = append(assigneeIDs, a.ID)
}
projectColumnID, err := issue.ProjectColumnID(ctx)
if err != nil {
return nil, false, err
projectIDs := make([]int64, 0, len(issue.Projects))
for _, project := range issue.Projects {
projectIDs = append(projectIDs, project.ID)
}
if err := issue.Repo.LoadOwner(ctx); err != nil {
@@ -114,10 +114,11 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
LabelIDs: labels,
NoLabel: len(labels) == 0,
MilestoneID: issue.MilestoneID,
ProjectID: projectID,
ProjectColumnID: projectColumnID,
ProjectIDs: projectIDs,
NoProject: len(projectIDs) == 0,
PosterID: issue.PosterID,
AssigneeID: issue.AssigneeID,
AssigneeIDs: assigneeIDs,
NoAssignee: len(assigneeIDs) == 0,
MentionIDs: mentionIDs,
ReviewedIDs: reviewedIDs,
ReviewRequestedIDs: reviewRequestedIDs,