mirror of https://github.com/go-gitea/gitea.git
Merge 1f3f691427 into a440116a16
commit
0c79640926
@ -0,0 +1,34 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
// CodeSearchResultLanguage result of top languages count in search results
|
||||||
|
type CodeSearchResultLanguage struct {
|
||||||
|
Language string
|
||||||
|
Color string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CodeSearchResultLine struct {
|
||||||
|
LineNumber int `json:"line_number"`
|
||||||
|
RawContent string `json:"raw_content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CodeSearchResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Color string
|
||||||
|
Lines []CodeSearchResultLine
|
||||||
|
Sha string `json:"sha"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Repository *Repository `json:"repository"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CodeSearchResults struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
Items []CodeSearchResult `json:"items"`
|
||||||
|
Languages []CodeSearchResultLanguage `json:"languages,omitempty"`
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package code
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
access_model "code.gitea.io/gitea/models/perm/access"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/container"
|
||||||
|
"code.gitea.io/gitea/modules/indexer"
|
||||||
|
"code.gitea.io/gitea/modules/indexer/code"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
"code.gitea.io/gitea/services/convert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GlobalSearch search codes in all accessible repositories with the given keyword.
|
||||||
|
func GlobalSearch(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /search/code search GlobalSearch
|
||||||
|
// ---
|
||||||
|
// summary: Search for repositories
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: q
|
||||||
|
// in: query
|
||||||
|
// description: keyword
|
||||||
|
// type: string
|
||||||
|
// - name: repo
|
||||||
|
// in: query
|
||||||
|
// description: multiple repository names to search in
|
||||||
|
// type: string
|
||||||
|
// collectionFormat: multi
|
||||||
|
// - name: mode
|
||||||
|
// in: query
|
||||||
|
// description: include search of keyword within repository description
|
||||||
|
// type: string
|
||||||
|
// enum: [exact, words, fuzzy, regexp]
|
||||||
|
// - name: language
|
||||||
|
// in: query
|
||||||
|
// description: filter by programming language
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// - name: page
|
||||||
|
// in: query
|
||||||
|
// description: page number of results to return (1-based)
|
||||||
|
// type: integer
|
||||||
|
// - name: limit
|
||||||
|
// in: query
|
||||||
|
// description: page size of results
|
||||||
|
// type: integer
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/CodeSearchResults"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
if !setting.Indexer.RepoIndexerEnabled {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "Repository indexing is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := ctx.FormTrim("q")
|
||||||
|
if q == "" {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "Query cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
accessibleRepoIDs []int64
|
||||||
|
err error
|
||||||
|
isAdmin bool
|
||||||
|
)
|
||||||
|
if ctx.Doer != nil {
|
||||||
|
isAdmin = ctx.Doer.IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// guest user or non-admin user
|
||||||
|
if ctx.Doer == nil || !isAdmin {
|
||||||
|
accessibleRepoIDs, err = repo_model.FindUserCodeAccessibleRepoIDs(ctx, ctx.Doer)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repoNames := ctx.FormStrings("repo")
|
||||||
|
searchRepoIDs := make([]int64, 0, len(repoNames))
|
||||||
|
if len(repoNames) > 0 {
|
||||||
|
var err error
|
||||||
|
searchRepoIDs, err = repo_model.GetRepositoriesIDsByFullNames(ctx, repoNames)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(searchRepoIDs) > 0 {
|
||||||
|
for i := 0; i < len(searchRepoIDs); i++ {
|
||||||
|
if !slices.Contains(accessibleRepoIDs, searchRepoIDs[i]) {
|
||||||
|
searchRepoIDs = append(searchRepoIDs[:i], searchRepoIDs[i+1:]...)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(searchRepoIDs) > 0 {
|
||||||
|
accessibleRepoIDs = searchRepoIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
searchMode := indexer.SearchModeType(ctx.FormString("mode"))
|
||||||
|
listOpts := utils.GetListOptions(ctx)
|
||||||
|
|
||||||
|
total, results, languages, err := code.PerformSearch(ctx, &code.SearchOptions{
|
||||||
|
Keyword: q,
|
||||||
|
RepoIDs: accessibleRepoIDs,
|
||||||
|
Language: ctx.FormString("language"),
|
||||||
|
SearchMode: searchMode,
|
||||||
|
Paginator: &listOpts,
|
||||||
|
NoHighlight: true, // Default to no highlighting for performance, we don't need to highlight in the API search results
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetTotalCountHeader(int64(total))
|
||||||
|
searchResults := structs.CodeSearchResults{
|
||||||
|
TotalCount: int64(total),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lang := range languages {
|
||||||
|
searchResults.Languages = append(searchResults.Languages, structs.CodeSearchResultLanguage{
|
||||||
|
Language: lang.Language,
|
||||||
|
Color: lang.Color,
|
||||||
|
Count: lang.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
repoIDs := make(container.Set[int64], len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
repoIDs.Add(result.RepoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs.Values())
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions := make(map[int64]access_model.Permission)
|
||||||
|
for _, repo := range repos {
|
||||||
|
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissions[repo.ID] = permission
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
repo, ok := repos[result.RepoID]
|
||||||
|
if !ok {
|
||||||
|
log.Error("Repository with ID %d not found for search result: %v", result.RepoID, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/contents/%s?ref=%s", repo.APIURL(), util.PathEscapeSegments(result.Filename), url.PathEscape(result.CommitID))
|
||||||
|
htmlURL := fmt.Sprintf("%s/blob/%s/%s", repo.HTMLURL(), url.PathEscape(result.CommitID), util.PathEscapeSegments(result.Filename))
|
||||||
|
ret := structs.CodeSearchResult{
|
||||||
|
Name: path.Base(result.Filename),
|
||||||
|
Path: result.Filename,
|
||||||
|
Sha: result.CommitID,
|
||||||
|
URL: apiURL,
|
||||||
|
HTMLURL: htmlURL,
|
||||||
|
Language: result.Language,
|
||||||
|
Repository: convert.ToRepo(ctx, repo, permissions[repo.ID]),
|
||||||
|
}
|
||||||
|
for _, line := range result.Lines {
|
||||||
|
ret.Lines = append(ret.Lines, structs.CodeSearchResultLine{
|
||||||
|
LineNumber: line.Num,
|
||||||
|
RawContent: line.RawContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
searchResults.Items = append(searchResults.Items, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(200, searchResults)
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package swagger
|
||||||
|
|
||||||
|
import (
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodeSearchResults
|
||||||
|
// swagger:response CodeSearchResults
|
||||||
|
type swaggerResponseCodeSearchResults struct {
|
||||||
|
// in:body
|
||||||
|
Body api.CodeSearchResults `json:"body"`
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPISearchCodeNotLogin(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// test with no keyword
|
||||||
|
req := NewRequest(t, "GET", "/api/v1/search/code")
|
||||||
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", "/api/v1/search/code?q=Description")
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var apiCodeSearchResults api.CodeSearchResults
|
||||||
|
DecodeJSON(t, resp, &apiCodeSearchResults)
|
||||||
|
assert.Equal(t, int64(1), apiCodeSearchResults.TotalCount)
|
||||||
|
assert.Len(t, apiCodeSearchResults.Items, 1)
|
||||||
|
assert.Equal(t, "README.md", apiCodeSearchResults.Items[0].Name)
|
||||||
|
assert.Equal(t, "README.md", apiCodeSearchResults.Items[0].Path)
|
||||||
|
assert.Equal(t, "Markdown", apiCodeSearchResults.Items[0].Language)
|
||||||
|
assert.Len(t, apiCodeSearchResults.Items[0].Lines, 2)
|
||||||
|
assert.Equal(t, "\n", apiCodeSearchResults.Items[0].Lines[0].RawContent)
|
||||||
|
assert.Equal(t, "Description for repo1", apiCodeSearchResults.Items[0].Lines[1].RawContent)
|
||||||
|
|
||||||
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer gitRepo1.Close()
|
||||||
|
|
||||||
|
commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/contents/README.md?ref="+commitID, apiCodeSearchResults.Items[0].URL)
|
||||||
|
assert.Equal(t, setting.AppURL+"user2/repo1/blob/"+commitID+"/README.md", apiCodeSearchResults.Items[0].HTMLURL)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1), apiCodeSearchResults.Items[0].Repository.ID)
|
||||||
|
|
||||||
|
assert.Len(t, apiCodeSearchResults.Languages, 1)
|
||||||
|
assert.Equal(t, "Markdown", apiCodeSearchResults.Languages[0].Language)
|
||||||
|
assert.Equal(t, 1, apiCodeSearchResults.Languages[0].Count)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue