初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package elasticsearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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 = 3
|
||||
|
||||
var _ internal.Indexer = &Indexer{}
|
||||
|
||||
// Indexer implements Indexer interface
|
||||
type Indexer struct {
|
||||
*es.Indexer
|
||||
}
|
||||
|
||||
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
|
||||
// TODO: es supports fuzzy search, but our code doesn't at the moment, and actually the default fuzziness is already "AUTO"
|
||||
return indexer.SearchModesExactWords()
|
||||
}
|
||||
|
||||
// NewIndexer creates a new elasticsearch indexer
|
||||
func NewIndexer(url, indexerName string) *Indexer {
|
||||
return &Indexer{Indexer: es.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMapping = `
|
||||
{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "integer", "index": true },
|
||||
"repo_id": { "type": "integer", "index": true },
|
||||
"is_public": { "type": "boolean", "index": true },
|
||||
|
||||
"title": { "type": "text", "index": true },
|
||||
"content": { "type": "text", "index": true },
|
||||
"comments": { "type" : "text", "index": true },
|
||||
|
||||
"is_pull": { "type": "boolean", "index": true },
|
||||
"is_closed": { "type": "boolean", "index": true },
|
||||
"is_archived": { "type": "boolean", "index": true },
|
||||
"label_ids": { "type": "integer", "index": true },
|
||||
"no_label": { "type": "boolean", "index": true },
|
||||
"milestone_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 },
|
||||
"mention_ids": { "type": "integer", "index": true },
|
||||
"reviewed_ids": { "type": "integer", "index": true },
|
||||
"review_requested_ids": { "type": "integer", "index": true },
|
||||
"subscriber_ids": { "type": "integer", "index": true },
|
||||
"updated_unix": { "type": "integer", "index": true },
|
||||
|
||||
"created_unix": { "type": "integer", "index": true },
|
||||
"deadline_unix": { "type": "integer", "index": true },
|
||||
"comment_count": { "type": "integer", "index": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
// Index will save the index data
|
||||
func (b *Indexer) Index(ctx context.Context, issues ...*internal.IndexerData) error {
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
} else if len(issues) == 1 {
|
||||
issue := issues[0]
|
||||
return b.Indexer.Index(ctx, strconv.FormatInt(issue.ID, 10), issue)
|
||||
}
|
||||
|
||||
ops := make([]es.BulkOp, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
ops = append(ops, es.IndexOp(strconv.FormatInt(issue.ID, 10), issue))
|
||||
}
|
||||
return b.Bulk(graceful.GetManager().HammerContext(), ops)
|
||||
}
|
||||
|
||||
// Delete deletes indexes by ids
|
||||
func (b *Indexer) Delete(ctx context.Context, ids ...int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
} else if len(ids) == 1 {
|
||||
return b.Indexer.Delete(ctx, strconv.FormatInt(ids[0], 10))
|
||||
}
|
||||
|
||||
ops := make([]es.BulkOp, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
ops = append(ops, es.DeleteOp(strconv.FormatInt(id, 10)))
|
||||
}
|
||||
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 := 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 {
|
||||
mm = mm.Type(es.MultiMatchTypePhrasePrefix)
|
||||
} else {
|
||||
mm = mm.Type(es.MultiMatchTypeBestFields).Operator("and")
|
||||
}
|
||||
query.Must(mm)
|
||||
}
|
||||
|
||||
if len(options.RepoIDs) > 0 {
|
||||
q := es.NewBoolQuery()
|
||||
q.Should(es.TermsQuery("repo_id", es.ToAnySlice(options.RepoIDs)...))
|
||||
if options.AllPublic {
|
||||
q.Should(es.TermQuery("is_public", true))
|
||||
}
|
||||
query.Must(q)
|
||||
}
|
||||
|
||||
if options.IsPull.Has() {
|
||||
query.Must(es.TermQuery("is_pull", options.IsPull.Value()))
|
||||
}
|
||||
if options.IsClosed.Has() {
|
||||
query.Must(es.TermQuery("is_closed", options.IsClosed.Value()))
|
||||
}
|
||||
if options.IsArchived.Has() {
|
||||
query.Must(es.TermQuery("is_archived", options.IsArchived.Value()))
|
||||
}
|
||||
|
||||
if options.NoLabelOnly {
|
||||
query.Must(es.TermQuery("no_label", true))
|
||||
} else {
|
||||
if len(options.IncludedLabelIDs) > 0 {
|
||||
q := es.NewBoolQuery()
|
||||
for _, labelID := range options.IncludedLabelIDs {
|
||||
q.Must(es.TermQuery("label_ids", labelID))
|
||||
}
|
||||
query.Must(q)
|
||||
} else if len(options.IncludedAnyLabelIDs) > 0 {
|
||||
query.Must(es.TermsQuery("label_ids", es.ToAnySlice(options.IncludedAnyLabelIDs)...))
|
||||
}
|
||||
if len(options.ExcludedLabelIDs) > 0 {
|
||||
q := es.NewBoolQuery()
|
||||
for _, labelID := range options.ExcludedLabelIDs {
|
||||
q.MustNot(es.TermQuery("label_ids", labelID))
|
||||
}
|
||||
query.Must(q)
|
||||
}
|
||||
}
|
||||
|
||||
if len(options.MilestoneIDs) > 0 {
|
||||
query.Must(es.TermsQuery("milestone_id", es.ToAnySlice(options.MilestoneIDs)...))
|
||||
}
|
||||
|
||||
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(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))
|
||||
}
|
||||
}
|
||||
|
||||
if options.MentionID.Has() {
|
||||
query.Must(es.TermQuery("mention_ids", options.MentionID.Value()))
|
||||
}
|
||||
|
||||
if options.ReviewedID.Has() {
|
||||
query.Must(es.TermQuery("reviewed_ids", options.ReviewedID.Value()))
|
||||
}
|
||||
if options.ReviewRequestedID.Has() {
|
||||
query.Must(es.TermQuery("review_requested_ids", options.ReviewRequestedID.Value()))
|
||||
}
|
||||
|
||||
if options.SubscriberID.Has() {
|
||||
query.Must(es.TermQuery("subscriber_ids", options.SubscriberID.Value()))
|
||||
}
|
||||
|
||||
if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() {
|
||||
q := es.NewRangeQuery("updated_unix")
|
||||
if options.UpdatedAfterUnix.Has() {
|
||||
q.Gte(options.UpdatedAfterUnix.Value())
|
||||
}
|
||||
if options.UpdatedBeforeUnix.Has() {
|
||||
q.Lte(options.UpdatedBeforeUnix.Value())
|
||||
}
|
||||
query.Must(q)
|
||||
}
|
||||
|
||||
if options.SortBy == "" {
|
||||
options.SortBy = internal.SortByCreatedAsc
|
||||
}
|
||||
sortBy := []es.SortField{
|
||||
parseSortBy(options.SortBy),
|
||||
{Field: "id", Desc: true},
|
||||
}
|
||||
|
||||
// See https://stackoverflow.com/questions/35206409/elasticsearch-2-1-result-window-is-too-large-index-max-result-window/35221900
|
||||
// TODO: make it configurable since it's configurable in elasticsearch
|
||||
const maxPageSize = 10000
|
||||
|
||||
skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxPageSize)
|
||||
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, 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: resp.Total,
|
||||
Hits: hits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSortBy(sortBy internal.SortBy) es.SortField {
|
||||
field, desc := strings.CutPrefix(string(sortBy), "-")
|
||||
return es.SortField{Field: field, Desc: desc}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package elasticsearch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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
|
||||
rawURL := test.ExternalServiceHTTP(t, "TEST_ELASTICSEARCH_URL", "http://elastic:changeme@elasticsearch:9200")
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}, time.Minute, time.Second, "Expected elasticsearch to be up")
|
||||
|
||||
indexer := NewIndexer(rawURL, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix()))
|
||||
defer indexer.Close()
|
||||
|
||||
tests.TestIndexer(t, indexer)
|
||||
}
|
||||
Reference in New Issue
Block a user