Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions models/repo/repo_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -771,3 +771,29 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor
repos := make(RepositoryList, 0, opts.PageSize)
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos)
}

// GetRepositoriesIDsByFullNames returns repository IDs by their full names.
func GetRepositoriesIDsByFullNames(ctx context.Context, fullRepoNames []string) ([]int64, error) {
if len(fullRepoNames) == 0 {
return nil, nil
}

cond := builder.NewCond()
for _, name := range fullRepoNames {
ownerName, repoName, ok := strings.Cut(name, "/")
if !ok {
continue
}
cond = cond.Or(builder.Eq{"name": repoName, "owner_name": ownerName})
}

repoIDs := make([]int64, 0, len(fullRepoNames))
if err := db.GetEngine(ctx).
Where(cond).
Cols("id").
Table("repository").
Find(&repoIDs); err != nil {
return nil, fmt.Errorf("Find: %w", err)
}
return repoIDs, nil
}
2 changes: 2 additions & 0 deletions modules/indexer/code/internal/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type SearchOptions struct {

SearchMode indexer.SearchModeType

NoHighlight bool // If true, return raw content, else highlight the search results

db.Paginator
}

Expand Down
23 changes: 19 additions & 4 deletions modules/indexer/code/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ type Result struct {
}

type ResultLine struct {
Num int
Num int
RawContent string // Raw content of the line
// FormattedContent is the HTML formatted content of the line, it will only be set if Hightlight is true
FormattedContent template.HTML
}

Expand Down Expand Up @@ -86,7 +88,7 @@ func HighlightSearchResultCode(filename, language string, lineNums []int, code s
return lines
}

func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Result, error) {
func searchResult(result *internal.SearchResult, startIndex, endIndex int, noHighlight bool) (*Result, error) {
startLineNum := 1 + strings.Count(result.Content[:startIndex], "\n")

var formattedLinesBuffer bytes.Buffer
Expand Down Expand Up @@ -117,14 +119,27 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
index += len(line)
}

var lines []*ResultLine
if noHighlight {
lines = make([]*ResultLine, len(lineNums))
for i, lineNum := range lineNums {
lines[i] = &ResultLine{
Num: lineNum,
RawContent: contentLines[i],
}
}
} else {
lines = HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String())
}

return &Result{
RepoID: result.RepoID,
Filename: result.Filename,
CommitID: result.CommitID,
UpdatedUnix: result.UpdatedUnix,
Language: result.Language,
Color: result.Color,
Lines: HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()),
Lines: lines,
}, nil
}

Expand All @@ -143,7 +158,7 @@ func PerformSearch(ctx context.Context, opts *SearchOptions) (int, []*Result, []

for i, result := range results {
startIndex, endIndex := indices(result.Content, result.StartIndex, result.EndIndex)
displayResults[i], err = searchResult(result, startIndex, endIndex)
displayResults[i], err = searchResult(result, startIndex, endIndex, opts.NoHighlight)
if err != nil {
return 0, nil, nil, err
}
Expand Down
34 changes: 34 additions & 0 deletions modules/structs/repo_search.go
Original file line number Diff line number Diff line change
@@ -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"`
}
5 changes: 5 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import (
"code.gitea.io/gitea/routers/api/v1/org"
"code.gitea.io/gitea/routers/api/v1/packages"
"code.gitea.io/gitea/routers/api/v1/repo"
"code.gitea.io/gitea/routers/api/v1/repo/code"
"code.gitea.io/gitea/routers/api/v1/settings"
"code.gitea.io/gitea/routers/api/v1/user"
"code.gitea.io/gitea/routers/common"
Expand Down Expand Up @@ -1768,6 +1769,10 @@ func Routes() *web.Router {
m.Group("/topics", func() {
m.Get("/search", repo.TopicSearch)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))

m.Group("/search", func() {
m.Get("/code", code.GlobalSearch)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
}, sudo())

return m
Expand Down
197 changes: 197 additions & 0 deletions routers/api/v1/repo/code/search.go
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 15 additions & 0 deletions routers/api/v1/swagger/search.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading
Loading