Skip to content

Commit 7eebf0b

Browse files
committed
Support code search via API
1 parent 709535c commit 7eebf0b

File tree

9 files changed

+488
-4
lines changed

9 files changed

+488
-4
lines changed

models/repo/repo_list.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,3 +771,29 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor
771771
repos := make(RepositoryList, 0, opts.PageSize)
772772
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos)
773773
}
774+
775+
// GetRepositoriesIDsByFullNames returns repository IDs by their full names.
776+
func GetRepositoriesIDsByFullNames(ctx context.Context, fullRepoNames []string) ([]int64, error) {
777+
if len(fullRepoNames) == 0 {
778+
return nil, nil
779+
}
780+
781+
var cond builder.Cond = builder.NewCond()
782+
for _, name := range fullRepoNames {
783+
ownerName, repoName, ok := strings.Cut(name, "/")
784+
if !ok {
785+
continue
786+
}
787+
cond = cond.Or(builder.Eq{"name": repoName, "owner_name": ownerName})
788+
}
789+
790+
repoIDs := make([]int64, 0, len(fullRepoNames))
791+
if err := db.GetEngine(ctx).
792+
Where(cond).
793+
Cols("id").
794+
Table("repository").
795+
Find(&repoIDs); err != nil {
796+
return nil, fmt.Errorf("Find: %w", err)
797+
}
798+
return repoIDs, nil
799+
}

modules/indexer/code/internal/indexer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ type SearchOptions struct {
2929

3030
SearchMode indexer.SearchModeType
3131

32+
NoHighlight bool // If true, return raw content, else highlight the search results
33+
3234
db.Paginator
3335
}
3436

modules/indexer/code/search.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ type Result struct {
2626
}
2727

2828
type ResultLine struct {
29-
Num int
29+
Num int
30+
RawContent string // Raw content of the line
31+
// FormattedContent is the HTML formatted content of the line, it will only be set if Hightlight is true
3032
FormattedContent template.HTML
3133
}
3234

@@ -86,7 +88,7 @@ func HighlightSearchResultCode(filename, language string, lineNums []int, code s
8688
return lines
8789
}
8890

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

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

122+
var lines []*ResultLine
123+
if noHighlight {
124+
lines = make([]*ResultLine, len(lineNums))
125+
for i, lineNum := range lineNums {
126+
lines[i] = &ResultLine{
127+
Num: lineNum,
128+
RawContent: contentLines[i],
129+
}
130+
}
131+
} else {
132+
lines = HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String())
133+
}
134+
120135
return &Result{
121136
RepoID: result.RepoID,
122137
Filename: result.Filename,
123138
CommitID: result.CommitID,
124139
UpdatedUnix: result.UpdatedUnix,
125140
Language: result.Language,
126141
Color: result.Color,
127-
Lines: HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()),
142+
Lines: lines,
128143
}, nil
129144
}
130145

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

144159
for i, result := range results {
145160
startIndex, endIndex := indices(result.Content, result.StartIndex, result.EndIndex)
146-
displayResults[i], err = searchResult(result, startIndex, endIndex)
161+
displayResults[i], err = searchResult(result, startIndex, endIndex, opts.NoHighlight)
147162
if err != nil {
148163
return 0, nil, nil, err
149164
}

modules/structs/repo_search.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package structs
5+
6+
// CodeSearchResultLanguage result of top languages count in search results
7+
type CodeSearchResultLanguage struct {
8+
Language string
9+
Color string
10+
Count int
11+
}
12+
13+
type CodeSearchResultLine struct {
14+
LineNumber int `json:"line_number"`
15+
RawContent string `json:"raw_content"`
16+
}
17+
18+
type CodeSearchResult struct {
19+
Name string `json:"name"`
20+
Path string `json:"path"`
21+
Language string `json:"language"`
22+
Color string
23+
Lines []CodeSearchResultLine
24+
Sha string `json:"sha"`
25+
URL string `json:"url"`
26+
HTMLURL string `json:"html_url"`
27+
Repository *Repository `json:"repository"`
28+
}
29+
30+
type CodeSearchResults struct {
31+
TotalCount int64 `json:"total_count"`
32+
Items []CodeSearchResult `json:"items"`
33+
Languages []CodeSearchResultLanguage `json:"languages,omitempty"`
34+
}

routers/api/v1/api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import (
9090
"code.gitea.io/gitea/routers/api/v1/org"
9191
"code.gitea.io/gitea/routers/api/v1/packages"
9292
"code.gitea.io/gitea/routers/api/v1/repo"
93+
"code.gitea.io/gitea/routers/api/v1/repo/code"
9394
"code.gitea.io/gitea/routers/api/v1/settings"
9495
"code.gitea.io/gitea/routers/api/v1/user"
9596
"code.gitea.io/gitea/routers/common"
@@ -1768,6 +1769,10 @@ func Routes() *web.Router {
17681769
m.Group("/topics", func() {
17691770
m.Get("/search", repo.TopicSearch)
17701771
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
1772+
1773+
m.Group("/search", func() {
1774+
m.Get("/code", code.GlobalSearch)
1775+
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
17711776
}, sudo())
17721777

17731778
return m

routers/api/v1/repo/code/search.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package code
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/url"
7+
"path"
8+
"slices"
9+
10+
access_model "code.gitea.io/gitea/models/perm/access"
11+
repo_model "code.gitea.io/gitea/models/repo"
12+
"code.gitea.io/gitea/modules/container"
13+
"code.gitea.io/gitea/modules/indexer"
14+
"code.gitea.io/gitea/modules/indexer/code"
15+
"code.gitea.io/gitea/modules/log"
16+
"code.gitea.io/gitea/modules/setting"
17+
"code.gitea.io/gitea/modules/structs"
18+
"code.gitea.io/gitea/modules/util"
19+
"code.gitea.io/gitea/routers/api/v1/utils"
20+
"code.gitea.io/gitea/services/context"
21+
"code.gitea.io/gitea/services/convert"
22+
)
23+
24+
// GlobalSearch search codes in all accessible repositories with the given keyword.
25+
func GlobalSearch(ctx *context.APIContext) {
26+
// swagger:operation GET /search/code search GlobalSearch
27+
// ---
28+
// summary: Search for repositories
29+
// produces:
30+
// - application/json
31+
// parameters:
32+
// - name: q
33+
// in: query
34+
// description: keyword
35+
// type: string
36+
// - name: repo
37+
// in: query
38+
// description: multiple repository names to search in
39+
// type: string
40+
// collectionFormat: multi
41+
// - name: mode
42+
// in: query
43+
// description: include search of keyword within repository description
44+
// type: string
45+
// enum: [exact, words, fuzzy, regexp]
46+
// - name: language
47+
// in: query
48+
// description: filter by programming language
49+
// type: integer
50+
// format: int64
51+
// - name: page
52+
// in: query
53+
// description: page number of results to return (1-based)
54+
// type: integer
55+
// - name: limit
56+
// in: query
57+
// description: page size of results
58+
// type: integer
59+
// responses:
60+
// "200":
61+
// "$ref": "#/responses/CodeSearchResults"
62+
// "422":
63+
// "$ref": "#/responses/validationError"
64+
65+
if !setting.Indexer.RepoIndexerEnabled {
66+
ctx.APIError(http.StatusBadRequest, "Repository indexing is disabled")
67+
return
68+
}
69+
70+
q := ctx.FormTrim("q")
71+
if q == "" {
72+
ctx.APIError(http.StatusUnprocessableEntity, "Query cannot be empty")
73+
return
74+
}
75+
76+
var (
77+
accessibleRepoIDs []int64
78+
err error
79+
isAdmin bool
80+
)
81+
if ctx.Doer != nil {
82+
isAdmin = ctx.Doer.IsAdmin
83+
}
84+
85+
// guest user or non-admin user
86+
if ctx.Doer == nil || !isAdmin {
87+
accessibleRepoIDs, err = repo_model.FindUserCodeAccessibleRepoIDs(ctx, ctx.Doer)
88+
if err != nil {
89+
ctx.APIErrorInternal(err)
90+
return
91+
}
92+
}
93+
94+
repoNames := ctx.FormStrings("repo")
95+
searchRepoIDs := make([]int64, 0, len(repoNames))
96+
if len(repoNames) > 0 {
97+
var err error
98+
searchRepoIDs, err = repo_model.GetRepositoriesIDsByFullNames(ctx, repoNames)
99+
if err != nil {
100+
ctx.APIErrorInternal(err)
101+
return
102+
}
103+
}
104+
if len(searchRepoIDs) > 0 {
105+
for i := 0; i < len(searchRepoIDs); i++ {
106+
if !slices.Contains(accessibleRepoIDs, searchRepoIDs[i]) {
107+
searchRepoIDs = append(searchRepoIDs[:i], searchRepoIDs[i+1:]...)
108+
i--
109+
}
110+
}
111+
}
112+
if len(searchRepoIDs) > 0 {
113+
accessibleRepoIDs = searchRepoIDs
114+
}
115+
116+
searchMode := indexer.SearchModeType(ctx.FormString("mode"))
117+
listOpts := utils.GetListOptions(ctx)
118+
119+
total, results, languages, err := code.PerformSearch(ctx, &code.SearchOptions{
120+
Keyword: q,
121+
RepoIDs: accessibleRepoIDs,
122+
Language: ctx.FormString("language"),
123+
SearchMode: searchMode,
124+
Paginator: &listOpts,
125+
NoHighlight: true, // Default to no highlighting for performance, we don't need to highlight in the API search results
126+
})
127+
if err != nil {
128+
ctx.APIErrorInternal(err)
129+
return
130+
}
131+
132+
ctx.SetTotalCountHeader(int64(total))
133+
searchResults := structs.CodeSearchResults{
134+
TotalCount: int64(total),
135+
}
136+
137+
for _, lang := range languages {
138+
searchResults.Languages = append(searchResults.Languages, structs.CodeSearchResultLanguage{
139+
Language: lang.Language,
140+
Color: lang.Color,
141+
Count: lang.Count,
142+
})
143+
}
144+
145+
repoIDs := make(container.Set[int64], len(results))
146+
for _, result := range results {
147+
repoIDs.Add(result.RepoID)
148+
}
149+
150+
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs.Values())
151+
if err != nil {
152+
ctx.APIErrorInternal(err)
153+
return
154+
}
155+
156+
permissions := make(map[int64]access_model.Permission)
157+
for _, repo := range repos {
158+
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
159+
if err != nil {
160+
ctx.APIErrorInternal(err)
161+
return
162+
}
163+
permissions[repo.ID] = permission
164+
}
165+
166+
for _, result := range results {
167+
repo, ok := repos[result.RepoID]
168+
if !ok {
169+
log.Error("Repository with ID %d not found for search result: %v", result.RepoID, result)
170+
continue
171+
}
172+
173+
apiURL := fmt.Sprintf("%s/contents/%s?ref=%s", repo.APIURL(), util.PathEscapeSegments(result.Filename), url.PathEscape(result.CommitID))
174+
htmlURL := fmt.Sprintf("%s/blob/%s/%s", repo.HTMLURL(), url.PathEscape(result.CommitID), util.PathEscapeSegments(result.Filename))
175+
ret := structs.CodeSearchResult{
176+
Name: path.Base(result.Filename),
177+
Path: result.Filename,
178+
Sha: result.CommitID,
179+
URL: apiURL,
180+
HTMLURL: htmlURL,
181+
Language: result.Language,
182+
Repository: convert.ToRepo(ctx, repo, permissions[repo.ID]),
183+
}
184+
for _, line := range result.Lines {
185+
ret.Lines = append(ret.Lines, structs.CodeSearchResultLine{
186+
LineNumber: line.Num,
187+
RawContent: line.RawContent,
188+
})
189+
}
190+
searchResults.Items = append(searchResults.Items, ret)
191+
}
192+
193+
ctx.JSON(200, searchResults)
194+
}

routers/api/v1/swagger/search.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package swagger
5+
6+
import (
7+
api "code.gitea.io/gitea/modules/structs"
8+
)
9+
10+
// CodeSearchResults
11+
// swagger:response CodeSearchResults
12+
type swaggerResponseCodeSearchResults struct {
13+
// in:body
14+
Body api.CodeSearchResults `json:"body"`
15+
}

0 commit comments

Comments
 (0)