mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-29 11:11:57 +00:00
fix(indexer): search code as case-insensitive literal substrings (#40028)
Code search now matches case-insensitive literal substrings like GitHub, in every script. Word tokenizers can't do this: the ES pattern dropped every non-ASCII word, and the `standard` tokenizer keeps `console.log` as one token. - Elasticsearch indexes positional trigrams, plus 1-2 grams for short terms - Bleve indexes distinct 1-3 grams as a candidate filter and verifies candidates against the content before paging, because phrase queries over trigrams are quadratic in repeated grams - Snippet offsets come from the original content, so `<em>` in sources and width-changing lowercase no longer misplace them - `search_mode=fuzzy` requests behave like Words, the UI never offered fuzzy for code search - The ES code indexer test now runs in CI, it was skipped before There are no UI changes. Both code indexes rebuild after upgrade. Fixes: https://github.com/go-gitea/gitea/issues/40027 Fixes: https://github.com/go-gitea/gitea/issues/16158 Fixes: https://github.com/go-gitea/gitea/issues/21359 --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
41 files changed
+330
-317
No files matched your search
@@ -215,7 +215,6 @@ jobs:
|
||||
run: GITEA_TEST_DATABASE=mysql make test-integration
|
||||
env:
|
||||
TAGS: bindata
|
||||
TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200"
|
||||
|
||||
test-mssql:
|
||||
if: needs.files-changed.outputs.backend == 'true'
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/analyze"
|
||||
@@ -68,7 +70,7 @@ const (
|
||||
repoIndexerAnalyzer = "repoIndexerAnalyzer"
|
||||
filenameIndexerAnalyzer = "filenameIndexerAnalyzer"
|
||||
repoIndexerDocType = "repoIndexerDocType"
|
||||
repoIndexerLatestVersion = 11
|
||||
repoIndexerLatestVersion = 12
|
||||
)
|
||||
|
||||
// generateBleveIndexMapping generates a bleve index mapping for the repo indexer
|
||||
@@ -80,6 +82,8 @@ func generateBleveIndexMapping() (mapping.IndexMapping, error) {
|
||||
|
||||
textFieldMapping := bleve.NewTextFieldMapping()
|
||||
textFieldMapping.IncludeInAll = false
|
||||
textFieldMapping.DocValues = false
|
||||
textFieldMapping.IncludeTermVectors = false
|
||||
docMapping.AddFieldMappingsAt("Content", textFieldMapping)
|
||||
|
||||
fileNamedMapping := bleve.NewTextFieldMapping()
|
||||
@@ -105,7 +109,7 @@ func generateBleveIndexMapping() (mapping.IndexMapping, error) {
|
||||
"type": analyzer_custom.Name,
|
||||
"char_filters": []string{},
|
||||
"tokenizer": codeTokenizerName,
|
||||
"token_filters": []string{unicodeNormalizeName, codeTokenFilterName, lowercase.Name},
|
||||
"token_filters": []string{},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,7 +145,6 @@ func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
|
||||
func init() {
|
||||
// due to bleve's design problem, the "Register" must be done in the main goroutine, otherwise data-race
|
||||
util.MustNoError(registry.RegisterTokenizer(codeTokenizerName, codeTokenizerConstructor))
|
||||
util.MustNoError(registry.RegisterTokenFilter(codeTokenFilterName, codeTokenFilterConstructor))
|
||||
util.MustNoError(registry.RegisterTokenizer(pathTokenizerName, pathTokenizerConstructor))
|
||||
}
|
||||
|
||||
@@ -266,25 +269,15 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
pathQuery.FieldVal = "Filename"
|
||||
pathQuery.SetBoost(10)
|
||||
|
||||
searchMode := util.IfZero(opts.SearchMode, b.SupportedSearchModes()[0].ModeValue)
|
||||
if searchMode == indexer.SearchModeExact {
|
||||
// 1.21 used NewPrefixQuery, but it seems not working well, and later releases changed to NewMatchPhraseQuery
|
||||
q := bleve.NewMatchPhraseQuery(opts.Keyword)
|
||||
q.Analyzer = repoIndexerAnalyzer
|
||||
q.FieldVal = "Content"
|
||||
contentQuery = q
|
||||
} else /* words */ {
|
||||
q := bleve.NewMatchQuery(opts.Keyword)
|
||||
q.FieldVal = "Content"
|
||||
q.Analyzer = repoIndexerAnalyzer
|
||||
if searchMode == indexer.SearchModeFuzzy {
|
||||
// this logic doesn't seem right, it is only used to pass the test-case `Keyword: "dESCRIPTION"`, which doesn't seem to be a real-life use-case.
|
||||
q.Fuzziness = inner_bleve.GuessFuzzinessByKeyword(opts.Keyword)
|
||||
} else {
|
||||
q.Operator = query.MatchQueryOperatorAnd
|
||||
}
|
||||
contentQuery = q
|
||||
terms := internal.SearchTerms(opts)
|
||||
if len(terms) == 0 {
|
||||
return 0, nil, nil, nil
|
||||
}
|
||||
contentQueries := make([]query.Query, 0, len(terms))
|
||||
for _, term := range terms {
|
||||
contentQueries = append(contentQueries, inner_bleve.MatchAndQuery(term, "Content", repoIndexerAnalyzer, 0))
|
||||
}
|
||||
contentQuery = bleve.NewConjunctionQuery(contentQueries...)
|
||||
|
||||
keywordQuery = bleve.NewDisjunctionQuery(contentQuery, pathQuery)
|
||||
|
||||
@@ -316,67 +309,79 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
}
|
||||
|
||||
from, pageSize := opts.GetSkipTake()
|
||||
searchRequest := bleve.NewSearchRequestOptions(indexerQuery, pageSize, from, false)
|
||||
searchRequest.Fields = []string{"Content", "Filename", "RepoID", "Language", "CommitID", "UpdatedAt"}
|
||||
searchRequest.IncludeLocations = true
|
||||
|
||||
if len(opts.Language) == 0 {
|
||||
searchRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
|
||||
// grams prove terms of up to 3 runes, longer ones are verified against the content, so fetch candidates until the page is full
|
||||
matched, scanned := 0, 0
|
||||
if !slices.ContainsFunc(terms, func(term string) bool { return utf8.RuneCountInString(term) > 3 }) {
|
||||
matched, scanned = from, from
|
||||
}
|
||||
var result *bleve.SearchResult
|
||||
var total int64
|
||||
searchResults := make([]*internal.SearchResult, 0, pageSize)
|
||||
for {
|
||||
size := from + pageSize - matched
|
||||
searchRequest := bleve.NewSearchRequestOptions(indexerQuery, size, scanned, false)
|
||||
searchRequest.Fields = []string{"Content", "Filename", "RepoID", "Language", "CommitID", "UpdatedAt"}
|
||||
searchRequest.IncludeLocations = true
|
||||
if result == nil && len(opts.Language) == 0 {
|
||||
searchRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
|
||||
}
|
||||
searchRequest.SortBy([]string{"-_score", "UpdatedAt"})
|
||||
|
||||
searchRequest.SortBy([]string{"-_score", "UpdatedAt"})
|
||||
batch, err := b.inner.Indexer.SearchInContext(ctx, searchRequest)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = batch
|
||||
}
|
||||
|
||||
result, err := b.inner.Indexer.SearchInContext(ctx, searchRequest)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
for _, hit := range batch.Hits {
|
||||
scanned++
|
||||
content, okContent := hit.Fields["Content"].(string)
|
||||
language, okLanguage := hit.Fields["Language"].(string)
|
||||
commitID, okCommitID := hit.Fields["CommitID"].(string)
|
||||
updatedAt, okUpdatedAt := hit.Fields["UpdatedAt"].(string)
|
||||
repoID, okRepoID := hit.Fields["RepoID"].(float64)
|
||||
if !okContent || !okLanguage || !okCommitID || !okUpdatedAt || !okRepoID {
|
||||
hitFieldsJson, _ := json.Marshal(hit.Fields)
|
||||
setting.PanicInDevOrTesting("unexpected field types in search hit %q: %s", hit.ID, string(hitFieldsJson))
|
||||
return 0, nil, nil, fmt.Errorf("unexpected field types in search hit %q", hit.ID)
|
||||
}
|
||||
|
||||
var startIndex, endIndex int
|
||||
if len(hit.Locations["Filename"]) > 0 {
|
||||
startIndex, endIndex = internal.FilenameMatchIndexPos(content)
|
||||
} else if startIndex, endIndex = internal.ContentMatchIndexPos(content, terms); startIndex < 0 {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
if matched <= from {
|
||||
continue
|
||||
}
|
||||
|
||||
var updatedUnix timeutil.TimeStamp
|
||||
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
|
||||
updatedUnix = timeutil.TimeStamp(t.Unix())
|
||||
}
|
||||
searchResults = append(searchResults, &internal.SearchResult{
|
||||
RepoID: int64(repoID),
|
||||
StartIndex: startIndex,
|
||||
EndIndex: endIndex,
|
||||
Filename: internal.FilenameOfIndexerID(hit.ID),
|
||||
Content: content,
|
||||
CommitID: commitID,
|
||||
UpdatedUnix: updatedUnix,
|
||||
Language: language,
|
||||
Color: enry.GetColor(language),
|
||||
})
|
||||
}
|
||||
if matched == from+pageSize || len(batch.Hits) < size {
|
||||
total = max(int64(matched), int64(matched)+int64(batch.Total)-int64(scanned))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
total := int64(result.Total)
|
||||
|
||||
searchResults := make([]*internal.SearchResult, len(result.Hits))
|
||||
for i, hit := range result.Hits {
|
||||
content, okContent := hit.Fields["Content"].(string)
|
||||
language, okLanguage := hit.Fields["Language"].(string)
|
||||
commitID, okCommitID := hit.Fields["CommitID"].(string)
|
||||
updatedAt, okUpdatedAt := hit.Fields["UpdatedAt"].(string)
|
||||
repoID, okRepoID := hit.Fields["RepoID"].(float64)
|
||||
if !okContent || !okLanguage || !okCommitID || !okUpdatedAt || !okRepoID {
|
||||
hitFieldsJson, _ := json.Marshal(hit.Fields)
|
||||
setting.PanicInDevOrTesting("unexpected field types in search hit %q: %s", hit.ID, string(hitFieldsJson))
|
||||
return 0, nil, nil, fmt.Errorf("unexpected field types in search hit %q", hit.ID)
|
||||
}
|
||||
|
||||
startIndex, endIndex := -1, -1
|
||||
for _, locations := range hit.Locations["Content"] {
|
||||
location := locations[0]
|
||||
locationStart := int(location.Start)
|
||||
locationEnd := int(location.End)
|
||||
if startIndex < 0 || locationStart < startIndex {
|
||||
startIndex = locationStart
|
||||
}
|
||||
if endIndex < 0 || locationEnd > endIndex {
|
||||
endIndex = locationEnd
|
||||
}
|
||||
}
|
||||
if len(hit.Locations["Filename"]) > 0 {
|
||||
startIndex, endIndex = internal.FilenameMatchIndexPos(content)
|
||||
}
|
||||
|
||||
var updatedUnix timeutil.TimeStamp
|
||||
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
|
||||
updatedUnix = timeutil.TimeStamp(t.Unix())
|
||||
}
|
||||
searchResults[i] = &internal.SearchResult{
|
||||
RepoID: int64(repoID),
|
||||
StartIndex: startIndex,
|
||||
EndIndex: endIndex,
|
||||
Filename: internal.FilenameOfIndexerID(hit.ID),
|
||||
Content: content,
|
||||
CommitID: commitID,
|
||||
UpdatedUnix: updatedUnix,
|
||||
Language: language,
|
||||
Color: enry.GetColor(language),
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, nil, nil, nil
|
||||
}
|
||||
|
||||
searchResultLanguages := make([]*internal.SearchResultLanguages, 0, 10)
|
||||
@@ -384,9 +389,9 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
// Use separate query to go get all language counts
|
||||
facetRequest := bleve.NewSearchRequestOptions(facetQuery, 1, 0, false)
|
||||
facetRequest.Fields = []string{"Content", "RepoID", "Language", "CommitID", "UpdatedAt"}
|
||||
facetRequest.IncludeLocations = true
|
||||
facetRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
|
||||
|
||||
var err error
|
||||
if result, err = b.inner.Indexer.Search(facetRequest); err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
indexer_module "gitea.dev/modules/indexer"
|
||||
"gitea.dev/modules/indexer/code/internal"
|
||||
inner_bleve "gitea.dev/modules/indexer/internal/bleve"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -32,7 +35,7 @@ func TestBleveIndexerTokenFilter(t *testing.T) {
|
||||
expectedIDs []int64
|
||||
}{
|
||||
{keyword: "12345", expectedIDs: []int64{2}},
|
||||
{keyword: "DNS", expectedIDs: []int64{}},
|
||||
{keyword: "DNS", expectedIDs: []int64{2}},
|
||||
{keyword: "mdns", expectedIDs: []int64{2}},
|
||||
{keyword: "port", expectedIDs: []int64{2}},
|
||||
{keyword: "port2", expectedIDs: []int64{2}},
|
||||
@@ -54,6 +57,38 @@ func TestBleveIndexerTokenFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBleveIndexerPagesVerifiedCandidates(t *testing.T) {
|
||||
indexer := NewIndexer(t.TempDir())
|
||||
defer indexer.Close()
|
||||
_, err := indexer.Init(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
batch := inner_bleve.NewFlushingBatch(indexer.inner.Indexer, maxBatchSize)
|
||||
for i, content := range []string{"abc bcd cde def", "abcdef", "abc bcd cde def", "abcdef", "abc bcd cde def"} {
|
||||
filename := strconv.Itoa(i)
|
||||
require.NoError(t, batch.Index(internal.FilenameIndexerID(1, filename), &RepoIndexerData{RepoID: 1, Filename: filename, Content: content, UpdatedAt: time.Now()}))
|
||||
}
|
||||
require.NoError(t, batch.Flush())
|
||||
|
||||
var filenames []string
|
||||
for page := 1; page <= 3; page++ {
|
||||
total, results, _, err := indexer.Search(t.Context(), &internal.SearchOptions{
|
||||
Paginator: &db.ListOptions{Page: page, PageSize: 1},
|
||||
Keyword: "abcdef",
|
||||
SearchMode: indexer_module.SearchModeExact,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, util.Iif(page < 3, 1, 0))
|
||||
if page == 3 {
|
||||
assert.EqualValues(t, 2, total)
|
||||
}
|
||||
for _, result := range results {
|
||||
filenames = append(filenames, result.Filename)
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"1", "3"}, filenames)
|
||||
}
|
||||
|
||||
func searchResultIDs(result []*internal.SearchResult) []int64 {
|
||||
ids := make([]int64, 0, len(result))
|
||||
for _, hit := range result {
|
||||
|
||||
@@ -4,55 +4,38 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"unicode"
|
||||
"bytes"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/blevesearch/bleve/v2/analysis"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/character"
|
||||
"github.com/blevesearch/bleve/v2/registry"
|
||||
)
|
||||
|
||||
const codeTokenizerName = "codeTokenizer"
|
||||
|
||||
func codeTokenizerConstructor(_ map[string]any, _ *registry.Cache) (analysis.Tokenizer, error) {
|
||||
// Old code used "letter" tokenizer which doesn't support CJK.
|
||||
// Here it still doesn't support CJK, since there is no usable CJK tokenizer at the moment.
|
||||
return character.NewCharacterTokenizer(func(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsNumber(r)
|
||||
}), nil
|
||||
}
|
||||
// codeTokenizer emits every distinct lowercase gram of 1 to 3 runes once, search verifies longer terms against the content
|
||||
type codeTokenizer struct{}
|
||||
|
||||
const codeTokenFilterName = "codeTokenFilter"
|
||||
|
||||
type codeTokenFilter struct {
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
func (c codeTokenFilter) Filter(stream analysis.TokenStream) (ret analysis.TokenStream) {
|
||||
// split one token to "letter" parts and "number" parts (to keep the old behavior).
|
||||
// e.g.: input token="port123", then the output tokens are "port123", "port", "123"
|
||||
for _, token := range stream {
|
||||
ret = append(ret, token)
|
||||
m := c.re.FindAllIndex(token.Term, -1)
|
||||
if len(m) > 1 {
|
||||
for _, it := range m {
|
||||
p1, p2 := it[0], it[1]
|
||||
t := &analysis.Token{
|
||||
Start: token.Start + p1,
|
||||
End: token.Start + p2,
|
||||
Term: token.Term[p1:p2],
|
||||
Position: token.Position,
|
||||
Type: analysis.AlphaNumeric,
|
||||
}
|
||||
ret = append(ret, t)
|
||||
func (codeTokenizer) Tokenize(input []byte) analysis.TokenStream {
|
||||
lower := bytes.ToLower(input)
|
||||
seen := make(map[string]bool)
|
||||
var tokens analysis.TokenStream
|
||||
var starts [3]int
|
||||
for end, count := 0, 0; end < len(lower); {
|
||||
starts[0], starts[1], starts[2] = starts[1], starts[2], end
|
||||
count = min(count+1, len(starts))
|
||||
_, size := utf8.DecodeRune(lower[end:])
|
||||
end += size
|
||||
for _, start := range starts[len(starts)-count:] {
|
||||
if gram := lower[start:end]; !seen[string(gram)] {
|
||||
seen[string(gram)] = true
|
||||
tokens = append(tokens, &analysis.Token{Start: start, End: end, Term: gram, Position: len(tokens) + 1, Type: analysis.AlphaNumeric})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
return tokens
|
||||
}
|
||||
|
||||
func codeTokenFilterConstructor(_ map[string]any, _ *registry.Cache) (analysis.TokenFilter, error) {
|
||||
return &codeTokenFilter{
|
||||
re: regexp.MustCompile("[a-zA-Z]+|[0-9]+"),
|
||||
}, nil
|
||||
func codeTokenizerConstructor(_config map[string]any, _cache *registry.Cache) (analysis.Tokenizer, error) {
|
||||
return codeTokenizer{}, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/analyze"
|
||||
@@ -23,12 +24,11 @@ import (
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/go-enry/go-enry/v2"
|
||||
)
|
||||
|
||||
const esRepoIndexerLatestVersion = 3
|
||||
const esRepoIndexerLatestVersion = 4
|
||||
|
||||
var _ internal.Indexer = &Indexer{}
|
||||
|
||||
@@ -55,6 +55,10 @@ const (
|
||||
"tokenizer": "content_tokenizer",
|
||||
"filter" : ["lowercase"]
|
||||
},
|
||||
"content_short_analyzer": {
|
||||
"tokenizer": "content_short_tokenizer",
|
||||
"filter" : ["lowercase"]
|
||||
},
|
||||
"filename_path_analyzer": {
|
||||
"tokenizer": "path_tokenizer"
|
||||
},
|
||||
@@ -64,8 +68,14 @@ const (
|
||||
},
|
||||
"tokenizer": {
|
||||
"content_tokenizer": {
|
||||
"type": "simple_pattern_split",
|
||||
"pattern": "[^a-zA-Z0-9]"
|
||||
"type": "ngram",
|
||||
"min_gram": 3,
|
||||
"max_gram": 3
|
||||
},
|
||||
"content_short_tokenizer": {
|
||||
"type": "ngram",
|
||||
"min_gram": 1,
|
||||
"max_gram": 2
|
||||
},
|
||||
"path_tokenizer": {
|
||||
"type": "path_hierarchy",
|
||||
@@ -87,7 +97,6 @@ const (
|
||||
},
|
||||
"filename": {
|
||||
"type": "text",
|
||||
"term_vector": "with_positions_offsets",
|
||||
"index": true,
|
||||
"fields": {
|
||||
"path": {
|
||||
@@ -102,9 +111,16 @@ const (
|
||||
},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"term_vector": "with_positions_offsets",
|
||||
"index": true,
|
||||
"analyzer": "content_analyzer"
|
||||
"analyzer": "content_analyzer",
|
||||
"fields": {
|
||||
"short": {
|
||||
"type": "text",
|
||||
"analyzer": "content_short_analyzer",
|
||||
"index_options": "docs",
|
||||
"norms": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"commit_id": {
|
||||
"type": "keyword",
|
||||
@@ -236,23 +252,7 @@ func (b *Indexer) doDelete(ctx context.Context, repoID int64) error {
|
||||
return b.DeleteByQuery(ctx, es.TermsQuery("repo_id", repoID))
|
||||
}
|
||||
|
||||
// contentMatchIndexPos find words positions for start and the following end on content. It will
|
||||
// return the beginning position of the first start and the ending position of the
|
||||
// first end following the start string.
|
||||
// If not found any of the positions, it will return -1, -1.
|
||||
func contentMatchIndexPos(content, start, end string) (int, int) {
|
||||
startIdx := strings.Index(content, start)
|
||||
if startIdx < 0 {
|
||||
return -1, -1
|
||||
}
|
||||
endIdx := strings.Index(content[startIdx+len(start):], end)
|
||||
if endIdx < 0 {
|
||||
return -1, -1
|
||||
}
|
||||
return startIdx, (startIdx + len(start) + endIdx + len(end)) - 9 // remove the length <em></em> since we give Content the original data
|
||||
}
|
||||
|
||||
func convertResult(searchResult *es.SearchResponse, kw string, pageSize int) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
|
||||
func convertResult(searchResult *es.SearchResponse, terms []string, pageSize int) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
|
||||
hits := make([]*internal.SearchResult, 0, pageSize)
|
||||
for _, hit := range searchResult.Hits {
|
||||
repoID, fileName := internal.ParseIndexerID(hit.ID)
|
||||
@@ -270,22 +270,9 @@ func convertResult(searchResult *es.SearchResponse, kw string, pageSize int) (in
|
||||
return 0, nil, nil, fmt.Errorf("unexpected field types in search hit %q", hit.ID)
|
||||
}
|
||||
|
||||
// FIXME: There is no way to get the position the keyword on the content currently on the same request.
|
||||
// So we get it from content, this may made the query slower. See
|
||||
// https://discuss.elastic.co/t/fetching-position-of-keyword-in-matched-document/94291
|
||||
var startIndex, endIndex int
|
||||
if c, ok := hit.Highlight["filename"]; ok && len(c) > 0 {
|
||||
startIndex, endIndex := internal.ContentMatchIndexPos(content, terms)
|
||||
if startIndex < 0 {
|
||||
startIndex, endIndex = internal.FilenameMatchIndexPos(content)
|
||||
} else if c, ok := hit.Highlight["content"]; ok && len(c) > 0 {
|
||||
// FIXME: Since the highlighting content will include <em> and </em> for the keywords,
|
||||
// now we should find the positions. But how to avoid html content which contains the
|
||||
// <em> and </em> tags? If elastic search has handled that?
|
||||
startIndex, endIndex = contentMatchIndexPos(c[0], "<em>", "</em>")
|
||||
if startIndex == -1 {
|
||||
panic(fmt.Sprintf("1===%s,,,%#v,,,%s", kw, hit.Highlight, c[0]))
|
||||
}
|
||||
} else {
|
||||
panic(fmt.Sprintf("2===%#v", hit.Highlight))
|
||||
}
|
||||
|
||||
hits = append(hits, &internal.SearchResult{
|
||||
@@ -328,11 +315,19 @@ func extractAggs(searchResult *es.SearchResponse) []*internal.SearchResultLangua
|
||||
|
||||
// Search searches for codes and language stats by given conditions.
|
||||
func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
|
||||
searchMode := util.IfZero(opts.SearchMode, b.SupportedSearchModes()[0].ModeValue)
|
||||
contentQuery := es.Query(es.NewMultiMatchQuery(opts.Keyword, "content").Type(es.MultiMatchTypeBestFields).Operator("and"))
|
||||
if searchMode == indexer.SearchModeExact {
|
||||
contentQuery = es.MatchPhraseQuery("content", opts.Keyword)
|
||||
terms := internal.SearchTerms(opts)
|
||||
if len(terms) == 0 {
|
||||
return 0, nil, nil, nil
|
||||
}
|
||||
contentQuery := es.NewBoolQuery()
|
||||
for _, term := range terms {
|
||||
if utf8.RuneCountInString(term) < 3 {
|
||||
contentQuery.Must(es.NewMultiMatchQuery(term, "content.short").Operator("and"))
|
||||
} else {
|
||||
contentQuery.Must(es.MatchPhraseQuery("content", term))
|
||||
}
|
||||
}
|
||||
|
||||
kwQuery := es.NewBoolQuery().Should(
|
||||
contentQuery,
|
||||
es.NewMultiMatchQuery(opts.Keyword, "filename^10").Type(es.MultiMatchTypePhrasePrefix),
|
||||
@@ -343,7 +338,6 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
}
|
||||
|
||||
start, pageSize := opts.GetSkipTake()
|
||||
kw := "<em>" + opts.Keyword + "</em>"
|
||||
languageAggs := map[string]any{
|
||||
"language": map[string]any{
|
||||
"terms": map[string]any{
|
||||
@@ -353,15 +347,6 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
},
|
||||
},
|
||||
}
|
||||
// number_of_fragments=0 returns the full highlighted content (no fragmentation).
|
||||
highlight := map[string]any{
|
||||
"fields": map[string]any{
|
||||
"content": map[string]any{},
|
||||
"filename": map[string]any{},
|
||||
},
|
||||
"number_of_fragments": 0,
|
||||
"type": "fvh",
|
||||
}
|
||||
sort := []es.SortField{
|
||||
{Field: "_score", Desc: true},
|
||||
{Field: "updated_at", Desc: false},
|
||||
@@ -375,12 +360,11 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
Size: pageSize,
|
||||
TrackTotal: true,
|
||||
Aggregations: languageAggs,
|
||||
Highlight: highlight,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
return convertResult(resp, kw, pageSize)
|
||||
return convertResult(resp, terms, pageSize)
|
||||
}
|
||||
|
||||
countResp, err := b.Indexer.Search(ctx, es.SearchRequest{
|
||||
@@ -400,12 +384,11 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
From: start,
|
||||
Size: pageSize,
|
||||
TrackTotal: true,
|
||||
Highlight: highlight,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
|
||||
total, hits, _, err := convertResult(resp, kw, pageSize)
|
||||
total, hits, _, err := convertResult(resp, terms, pageSize)
|
||||
return total, hits, extractAggs(countResp), err
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package elasticsearch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIndexPos(t *testing.T) {
|
||||
startIdx, endIdx := contentMatchIndexPos("test index start and end", "start", "end")
|
||||
assert.Equal(t, 11, startIdx)
|
||||
assert.Equal(t, 15, endIdx)
|
||||
}
|
||||
@@ -5,18 +5,19 @@ package code
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/git"
|
||||
indexer_module "gitea.dev/modules/indexer"
|
||||
"gitea.dev/modules/indexer/code/bleve"
|
||||
"gitea.dev/modules/indexer/code/elasticsearch"
|
||||
"gitea.dev/modules/indexer/code/internal"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
@@ -37,8 +38,29 @@ func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
const unicodeSearchContent = `Поставщик
|
||||
日本語のテキスト
|
||||
हिन्दी
|
||||
ภาษาไทย
|
||||
İSTANBUL
|
||||
<em>literal</em>
|
||||
abc bcd cde def
|
||||
`
|
||||
|
||||
func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 62})
|
||||
require.NoError(t, git.ForceFastImport(t.Context(), repo, []git.FastImportCommit{{
|
||||
Ref: "refs/heads/master",
|
||||
Files: []git.FastImportFile{
|
||||
{Path: "avocado.md", Content: "# repo1\n\npineaple pie of cucumber juice"},
|
||||
{Path: "cucumber.md", Content: "Salad is good for your health"},
|
||||
{Path: "example-file.js", Content: "console.log(\"Hello, World!\")"},
|
||||
{Path: "ham.md", Content: "This is also not cheese"},
|
||||
{Path: "potato/ham.md", Content: "This is not cheese"},
|
||||
{Path: "unicode.txt", Content: unicodeSearchContent},
|
||||
},
|
||||
}}))
|
||||
assert.NoError(t, setupRepositoryIndexes(t.Context(), indexer))
|
||||
// Wait for the index to catch up: ES/OpenSearch make writes visible
|
||||
// only after a refresh (default interval: 1s). Bleve is synchronous
|
||||
@@ -57,6 +79,7 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
Langs int
|
||||
SearchMode indexer_module.SearchModeType
|
||||
Results []codeSearchResult
|
||||
Match string
|
||||
}{
|
||||
// Search for an exact match on the contents of a file
|
||||
// This scenario yields a single result (the file README.md on the repo '1')
|
||||
@@ -89,6 +112,10 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
Filename: "README.md",
|
||||
Content: "# repo1\n\nDescription for repo1",
|
||||
},
|
||||
{
|
||||
Filename: "avocado.md",
|
||||
Content: "# repo1\n\npineaple pie of cucumber juice",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Search for an exact match on the contents of a file within the repo '2'.
|
||||
@@ -191,7 +218,7 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
},
|
||||
},
|
||||
// Search for matches on the contents of files within the repo '62'.
|
||||
// This scenario yields two results (both are based on contents, the first one is an exact match where as the second is a 'fuzzy' one)
|
||||
// This scenario yields two results (both are based on contents, the first one is an exact match where as the second is a words match)
|
||||
{
|
||||
RepoIDs: []int64{62},
|
||||
Keyword: "This is not cheese",
|
||||
@@ -209,10 +236,9 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
},
|
||||
// Search for matches on the contents of files regardless of case.
|
||||
{
|
||||
RepoIDs: nil,
|
||||
Keyword: "dESCRIPTION",
|
||||
Langs: 1,
|
||||
SearchMode: indexer_module.SearchModeFuzzy,
|
||||
RepoIDs: nil,
|
||||
Keyword: "dESCRIPTION",
|
||||
Langs: 1,
|
||||
Results: []codeSearchResult{
|
||||
{
|
||||
Filename: "README.md",
|
||||
@@ -257,6 +283,13 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{RepoIDs: []int64{62}, Keyword: "ставщ", Langs: 1, SearchMode: indexer_module.SearchModeExact, Results: []codeSearchResult{{Filename: "unicode.txt", Content: unicodeSearchContent}}},
|
||||
{RepoIDs: []int64{62}, Keyword: "テキスト", Langs: 1, Results: []codeSearchResult{{Filename: "unicode.txt", Content: unicodeSearchContent}}},
|
||||
{RepoIDs: []int64{62}, Keyword: "हिन्दी", Langs: 1, SearchMode: indexer_module.SearchModeExact, Results: []codeSearchResult{{Filename: "unicode.txt", Content: unicodeSearchContent}}},
|
||||
{RepoIDs: []int64{62}, Keyword: "iSTANBUL", Langs: 1, SearchMode: indexer_module.SearchModeExact, Match: "İSTANBUL", Results: []codeSearchResult{{Filename: "unicode.txt", Content: unicodeSearchContent}}},
|
||||
{RepoIDs: []int64{62}, Keyword: "literal", Langs: 1, Match: "literal", Results: []codeSearchResult{{Filename: "unicode.txt", Content: unicodeSearchContent}}},
|
||||
{RepoIDs: []int64{62}, Keyword: "日本 ภาษา", Langs: 1, Match: "日本", Results: []codeSearchResult{{Filename: "unicode.txt", Content: unicodeSearchContent}}},
|
||||
{RepoIDs: []int64{62}, Keyword: "abcdef", SearchMode: indexer_module.SearchModeExact},
|
||||
}
|
||||
|
||||
for _, kw := range keywords {
|
||||
@@ -272,14 +305,15 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, langs, kw.Langs)
|
||||
require.EqualValues(t, len(kw.Results), total)
|
||||
require.Len(t, res, len(kw.Results))
|
||||
|
||||
hits := make([]codeSearchResult, 0, len(res))
|
||||
|
||||
if total > 0 {
|
||||
assert.NotEmpty(t, kw.Results, "The given scenario does not provide any expected results")
|
||||
}
|
||||
|
||||
for _, hit := range res {
|
||||
if kw.Match != "" {
|
||||
require.Equal(t, kw.Match, hit.Content[hit.StartIndex:hit.EndIndex])
|
||||
}
|
||||
hits = append(hits, codeSearchResult{
|
||||
Filename: hit.Filename,
|
||||
Content: hit.Content,
|
||||
@@ -307,7 +341,6 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
||||
|
||||
func TestBleveIndexAndSearch(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
defer test.MockVariableValue(&setting.Indexer.TypeBleveMaxFuzzniess, 2)()
|
||||
dir := t.TempDir()
|
||||
|
||||
idx := bleve.NewIndexer(dir)
|
||||
@@ -322,13 +355,9 @@ func TestBleveIndexAndSearch(t *testing.T) {
|
||||
func TestESIndexAndSearch(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
u := os.Getenv("TEST_INDEXER_CODE_ES_URL")
|
||||
if u == "" {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
u := test.ExternalServiceHTTP(t, "TEST_ELASTICSEARCH_URL", "http://elasticsearch:9200")
|
||||
|
||||
indexer := elasticsearch.NewIndexer(u, "gitea_codes")
|
||||
indexer := elasticsearch.NewIndexer(u, fmt.Sprintf("test_code_indexer_%d", time.Now().UnixNano()))
|
||||
if _, err := indexer.Init(t.Context()); err != nil {
|
||||
if indexer != nil {
|
||||
indexer.Close()
|
||||
|
||||
@@ -5,7 +5,10 @@ package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.dev/modules/indexer"
|
||||
"gitea.dev/modules/indexer/internal"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
@@ -46,3 +49,43 @@ func FilenameMatchIndexPos(content string) (int, int) {
|
||||
}
|
||||
return 0, len(content)
|
||||
}
|
||||
|
||||
func SearchTerms(opts *SearchOptions) []string {
|
||||
if opts.Keyword == "" {
|
||||
return nil
|
||||
}
|
||||
if opts.SearchMode == "" || opts.SearchMode == indexer.SearchModeExact {
|
||||
return []string{opts.Keyword}
|
||||
} else {
|
||||
return strings.Fields(opts.Keyword)
|
||||
}
|
||||
}
|
||||
|
||||
// ContentMatchIndexPos returns the byte range of the earliest term in content, or -1, -1 unless all terms occur
|
||||
func ContentMatchIndexPos(content string, terms []string) (int, int) {
|
||||
lowerContent := strings.ToLower(content)
|
||||
start, end := -1, -1
|
||||
for _, term := range terms {
|
||||
lowerTerm := strings.ToLower(term)
|
||||
position := strings.Index(lowerContent, lowerTerm)
|
||||
if position < 0 {
|
||||
return -1, -1
|
||||
}
|
||||
if start < 0 || position < start {
|
||||
start, end = position, position+len(lowerTerm)
|
||||
}
|
||||
}
|
||||
lowerOffset, originalStart := 0, -1
|
||||
for offset := 0; offset < len(content); {
|
||||
character, size := utf8.DecodeRuneInString(content[offset:])
|
||||
if lowerOffset == start {
|
||||
originalStart = offset
|
||||
}
|
||||
lowerOffset += utf8.RuneLen(unicode.ToLower(character))
|
||||
offset += size
|
||||
if lowerOffset == end {
|
||||
return originalStart, offset
|
||||
}
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
This repository will be used to test code search. The snippet below shows its directory structure
|
||||
|
||||
.
|
||||
├── avocado.md
|
||||
├── cucumber.md
|
||||
├── ham.md
|
||||
├── potato
|
||||
| └── ham.md
|
||||
└── example-file.js
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
ORI_DIR=`pwd`
|
||||
SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
|
||||
cd "$ORI_DIR"
|
||||
for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
|
||||
sh "$SHELL_FOLDER/post-receive.d/$i"
|
||||
done
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
ORI_DIR=`pwd`
|
||||
SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
|
||||
cd "$ORI_DIR"
|
||||
for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
|
||||
sh "$SHELL_FOLDER/pre-receive.d/$i"
|
||||
done
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
ORI_DIR=`pwd`
|
||||
SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
|
||||
cd "$ORI_DIR"
|
||||
for i in `ls "$SHELL_FOLDER/proc-receive.d"`; do
|
||||
sh "$SHELL_FOLDER/proc-receive.d/$i"
|
||||
done
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" proc-receive
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
ORI_DIR=`pwd`
|
||||
SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
|
||||
cd "$ORI_DIR"
|
||||
for i in `ls "$SHELL_FOLDER/update.d"`; do
|
||||
sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
|
||||
done
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
|
||||
@@ -1,13 +0,0 @@
|
||||
90c1019714259b24fb81711d4416ac0f18667dfa refs/heads/DefaultBranch
|
||||
985f0301dba5e7b34be866819cd15ad3d8f508ee refs/heads/branch2
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1
|
||||
78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check
|
||||
9f894b61946fd2f7b8b9d8e370e4d62f915522f5 refs/heads/master
|
||||
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update
|
||||
4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check
|
||||
3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits
|
||||
4a357436d925b5c974181ff12a994538ddc5a269 refs/pull/2/head
|
||||
5f22f7d0d95d614d25a5b68592adb345a4b5c7fd refs/pull/3/head
|
||||
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/pull/5/head
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/tags/v1.1
|
||||
Whitespace-only changes.
@@ -1,2 +0,0 @@
|
||||
P pack-a7bef76cf6e2b46bc816936ab69306fb10aea571.pack
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,14 +0,0 @@
|
||||
# pack-refs with: peeled fully-peeled sorted
|
||||
90c1019714259b24fb81711d4416ac0f18667dfa refs/heads/DefaultBranch
|
||||
985f0301dba5e7b34be866819cd15ad3d8f508ee refs/heads/branch2
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/develop
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/feature/1
|
||||
78fb907e3a3309eae4fe8fef030874cebbf1cd5e refs/heads/home-md-img-check
|
||||
9f894b61946fd2f7b8b9d8e370e4d62f915522f5 refs/heads/master
|
||||
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update
|
||||
4649299398e4d39a5c09eb4f534df6f1e1eb87cc refs/heads/sub-home-md-img-check
|
||||
3fa2f829675543ecfc16b2891aebe8bf0608a8f4 refs/notes/commits
|
||||
4a357436d925b5c974181ff12a994538ddc5a269 refs/pull/2/head
|
||||
5f22f7d0d95d614d25a5b68592adb345a4b5c7fd refs/pull/3/head
|
||||
62fb502a7172d4453f0322a2cc85bddffa57f07a refs/pull/5/head
|
||||
65f1bf27bc3bf70f64657658635e66094edbcb4d refs/tags/v1.1
|
||||
Whitespace-only changes.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
x+)JMU03d040031QHÒ+©(a¨é:ô㆖ÜÖo«Þ<KšÿQ@ô§Ü P¨dè‹ÔKÎU_#â?éû¿ãd™½¯"}•‡
|
||||
BIN
Binary file not shown.
-2
@@ -1,2 +0,0 @@
|
||||
xО9В0@Qjџb.@д8“MB*Z®аxЖБВЋ#/·'вґЇшъ&†а
|
||||
(ХћJbНФZЛ¤Р�ОШy™$Оrњ{ћжОhЖ…€† …®е<ЄућS€рLz#—х'»zЯjж”›-&Юэ§Y]yХҐ11\Ўн‡q@D…p–ќ”вРcЈрЯ‚вN…sл<gсїЋK©
|
||||
Whitespace-only changes.
@@ -1 +0,0 @@
|
||||
ef6b814b610d8e7717aa0f71fbe5842bcf814697
|
||||
@@ -4,13 +4,13 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
)
|
||||
|
||||
func assertLinkPageComplete(t *testing.T, session *TestSession, link string, containStrings ...string) {
|
||||
t.Helper()
|
||||
req := NewRequest(t, "GET", link)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()), "Page did not complete: "+link)
|
||||
@@ -134,6 +135,7 @@ func testLinksNoLoginNotExist(t *testing.T) {
|
||||
}
|
||||
|
||||
func testLinksAsUser(t *testing.T) {
|
||||
// TOOD: it is questionable whether it's worth to keep these tests, these endpoints should have their own tests
|
||||
session := loginUser(t, "user2")
|
||||
links := []string{
|
||||
"/explore/repos",
|
||||
@@ -190,6 +192,11 @@ func testLinksAsUser(t *testing.T) {
|
||||
reqAPI := NewRequestf(t, "GET", "/api/v1/users/user2/repos")
|
||||
respAPI := MakeRequest(t, reqAPI, http.StatusOK)
|
||||
apiRepos := DecodeJSON(t, respAPI, []*api.Repository{})
|
||||
assert.NotEmpty(t, apiRepos)
|
||||
|
||||
defer test.MockVariableValue(&setting.DisableGitHooks, false)()
|
||||
_, err := db.GetEngine(t.Context()).ID(2).Cols("allow_git_hook").Update(&user_model.User{AllowGitHook: true})
|
||||
require.NoError(t, err)
|
||||
repoLinks := []string{
|
||||
"",
|
||||
"/issues",
|
||||
@@ -200,23 +207,21 @@ func testLinksAsUser(t *testing.T) {
|
||||
"/settings/collaboration",
|
||||
"/settings/branches",
|
||||
"/settings/hooks",
|
||||
// FIXME: below links should return 200 but 404 ??
|
||||
//"/settings/hooks/git",
|
||||
//"/settings/hooks/git/pre-receive",
|
||||
//"/settings/hooks/git/update",
|
||||
//"/settings/hooks/git/post-receive",
|
||||
|
||||
"/settings/hooks/git",
|
||||
"/settings/hooks/git/pre-receive",
|
||||
"/settings/hooks/git/update",
|
||||
"/settings/hooks/git/post-receive",
|
||||
|
||||
"/settings/keys",
|
||||
"/releases",
|
||||
"/releases/new",
|
||||
//"/wiki/_pages",
|
||||
"/wiki/?action=_pages",
|
||||
"/wiki/?action=_new",
|
||||
"/activity",
|
||||
}
|
||||
for _, repo := range apiRepos {
|
||||
for _, link := range repoLinks {
|
||||
link = fmt.Sprintf("/user2/%s%s", repo.Name, link)
|
||||
assertLinkPageComplete(t, session, link)
|
||||
}
|
||||
for _, link := range repoLinks {
|
||||
assertLinkPageComplete(t, session, "/user2/repo1"+link)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/routers"
|
||||
@@ -25,6 +28,17 @@ func TestRepoDownloadArchive(t *testing.T) {
|
||||
defer test.MockVariableValue(&web.GzipMinSize, 10)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
require.NoError(t, git.ForceFastImport(t.Context(), repo.CodeStorageRepo(), []git.FastImportCommit{{
|
||||
Ref: "refs/heads/archive-test",
|
||||
Files: []git.FastImportFile{
|
||||
{Path: "a.txt", Content: "root text file\n"},
|
||||
{Path: "aaa.doc", Content: "root document\n"},
|
||||
{Path: "x/b.txt", Content: "nested text file\n"},
|
||||
{Path: "x/y/a.txt", Content: "deeply nested text file\n"},
|
||||
},
|
||||
}}))
|
||||
|
||||
t.Run("NoDuplicateCompression", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip")
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
@@ -37,7 +51,7 @@ func TestRepoDownloadArchive(t *testing.T) {
|
||||
|
||||
t.Run("Conditional", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Repository.StreamArchives, true)()
|
||||
archiveURL := "/user2/glob/archive/master.tar.gz"
|
||||
archiveURL := "/user2/repo1/archive/archive-test.tar.gz"
|
||||
etag := MakeRequest(t, NewRequest(t, "GET", archiveURL), http.StatusOK).Header().Get("ETag")
|
||||
assert.True(t, strings.HasPrefix(etag, `W/"`))
|
||||
MakeRequest(t, NewRequest(t, "GET", archiveURL).SetHeader("If-None-Match", etag), http.StatusNotModified)
|
||||
@@ -55,22 +69,22 @@ func TestRepoDownloadArchive(t *testing.T) {
|
||||
t.Run("SubPath", func(t *testing.T) {
|
||||
// When using "archiving and caching" approach, archiving with paths will always use streaming and never be cached
|
||||
defer test.MockVariableValue(&setting.Repository.StreamArchives, false)() // this can be removed if there is always streaming mode
|
||||
req := NewRequest(t, "GET", "/user2/glob/archive/master.tar.gz?path=aaa.doc&path=x/y")
|
||||
req := NewRequest(t, "GET", "/user2/repo1/archive/archive-test.tar.gz?path=aaa.doc&path=x/y")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
content, err := test.ReadAllTarGzContent(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, content["glob/a.txt"])
|
||||
assert.NotEmpty(t, content["glob/aaa.doc"])
|
||||
assert.Empty(t, content["glob/x/b.txt"])
|
||||
assert.NotEmpty(t, content["glob/x/y/a.txt"])
|
||||
assert.Empty(t, content["repo1/a.txt"])
|
||||
assert.NotEmpty(t, content["repo1/aaa.doc"])
|
||||
assert.Empty(t, content["repo1/x/b.txt"])
|
||||
assert.NotEmpty(t, content["repo1/x/y/a.txt"])
|
||||
|
||||
req = NewRequest(t, "GET", "/user2/glob/archive/master.tar.gz")
|
||||
req = NewRequest(t, "GET", "/user2/repo1/archive/archive-test.tar.gz")
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
content, err = test.ReadAllTarGzContent(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, content["glob/a.txt"])
|
||||
assert.NotEmpty(t, content["glob/aaa.doc"])
|
||||
assert.NotEmpty(t, content["glob/x/b.txt"])
|
||||
assert.NotEmpty(t, content["glob/x/y/a.txt"])
|
||||
assert.NotEmpty(t, content["repo1/a.txt"])
|
||||
assert.NotEmpty(t, content["repo1/aaa.doc"])
|
||||
assert.NotEmpty(t, content["repo1/x/b.txt"])
|
||||
assert.NotEmpty(t, content["repo1/x/y/a.txt"])
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
code_indexer "gitea.dev/modules/indexer/code"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func resultFilenames(doc *HTMLDoc) []string {
|
||||
@@ -40,16 +42,25 @@ func TestSearchRepo(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Indexer.ExcludePatterns, setting.IndexerGlobFromString("**/y/**"))()
|
||||
|
||||
repo, err = repo_model.GetRepositoryByOwnerAndName(t.Context(), "user2", "glob")
|
||||
assert.NoError(t, err)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, git.ForceFastImport(t.Context(), repo.CodeStorageRepo(), []git.FastImportCommit{{
|
||||
Ref: "refs/heads/master",
|
||||
Files: []git.FastImportFile{
|
||||
{Path: "a.txt", Content: "file1 loren ipsum\n"},
|
||||
{Path: "aaa.doc", Content: "file2 loren ipsum\n"},
|
||||
{Path: "x/b.txt", Content: "file3\n"},
|
||||
{Path: "x/y/a.txt", Content: "file4\n"},
|
||||
{Path: "x/y/z/a.txt", Content: "file5\n"},
|
||||
},
|
||||
}}))
|
||||
code_indexer.UpdateRepoIndexer(repo)
|
||||
|
||||
testSearch(t, "/user2/glob/search?q=loren&page=1", []string{"a.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=loren&page=1&t=match", []string{"a.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=file3&page=1", []string{"x/b.txt", "a.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=file3&page=1&t=match", []string{"x/b.txt", "a.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=file4&page=1&t=match", []string{"x/b.txt", "a.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=file5&page=1&t=match", []string{"x/b.txt", "a.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=file3&page=1", []string{"x/b.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=file3&page=1&t=match", []string{"x/b.txt"})
|
||||
testSearch(t, "/user2/glob/search?q=file4&page=1&t=match", []string{})
|
||||
testSearch(t, "/user2/glob/search?q=file5&page=1&t=match", []string{})
|
||||
}
|
||||
|
||||
func testSearch(t *testing.T, url string, expected []string) {
|
||||
|
||||
Reference in new issue
Block a user