code_search was calling GET /api/v1/repos/{owner}/{repo}/search?type=code — an
endpoint that does not exist. Confirmed against a live Gitea 1.25.5 instance's
swagger spec: only /repos/search, /repos/issues/search, /topics/search etc are
real. Gitea's own web UI code search falls back to server-side `git grep`
because no Repository Indexer is enabled here, and that fallback is an
HTML-only route, not JSON API — so no REST endpoint exists to call, working or
not. Every call to code_search 404'd on the real server.
This went undetected because the existing tests mocked the fantasy endpoint
directly (asserting the request path was .../search?type=code and handing back
a canned JSON envelope) — a textbook case of tests validating a fake instead of
the real system, giving false confidence the tool worked.
SearchCode now does the search itself: resolves the default branch, walks the
tree (GetTree), and substring-matches q (case-insensitive, literal — not a
regex, to keep behavior predictable and avoid a ReDoS surface from
user-supplied input) against fetched file contents (GetFileContents) — the same
approach Gitea's own indexer-less fallback uses, just client-side. Bounded by:
- codeSearchMaxFiles (2000) — files scanned per call
- codeSearchMaxFileSize (512KB, checked via the tree listing's own Size field,
before any fetch) — skip large blobs
- a binary-extension denylist checked before fetch, plus a null-byte content
check after fetch, for extensions the denylist misses
Pagination is over the full sorted result set, recomputed each call (no
server-side index to page through incrementally) — acceptable for the repo
sizes this targets; documented as a known limitation, not a hidden footgun.
The tool layer (internal/tools/code_search.go) is UNCHANGED — SearchCode's
signature and the []CodeSearchHit contract are identical, so this is fully
isolated to the client layer + its tests.
Tests: match found in tree, case-insensitive matching, binary extension
skipped WITHOUT fetching (asserted via the fake's fetch log, not just absent
from results), oversized file skipped without fetching, null-byte content
skipped after fetching, pagination across a full sorted set, empty-query
validation — plus the tool-layer single-repo and fan-out tests rewritten
against the same real endpoints (GetRepo/GetTree/GetFileContents +
ListRepos), replacing their fantasy-endpoint fakes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
5.0 KiB
Go
164 lines
5.0 KiB
Go
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type CodeSearchHit struct {
|
|
Path string `json:"path"`
|
|
Snippet string `json:"snippet"`
|
|
HTMLURL string `json:"html_url"`
|
|
Score float64 `json:"score,omitempty"`
|
|
}
|
|
|
|
// codeSearchMaxFiles bounds how many files a single SearchCode call will fetch
|
|
// and scan, so one call against a huge repo can't run indefinitely.
|
|
const codeSearchMaxFiles = 2000
|
|
|
|
// codeSearchMaxFileSize skips blobs larger than this (per the tree listing,
|
|
// before any fetch) — almost certainly binary/vendor/generated content, not
|
|
// worth the cost of fetching just to reject.
|
|
const codeSearchMaxFileSize = 512 * 1024
|
|
|
|
// codeSearchBinaryExts are skipped WITHOUT fetching — a cheap pre-filter for
|
|
// obviously-binary content by extension, checked against the tree listing.
|
|
var codeSearchBinaryExts = map[string]bool{
|
|
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".webp": true, ".bmp": true,
|
|
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, ".7z": true,
|
|
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, ".class": true, ".jar": true,
|
|
".woff": true, ".woff2": true, ".ttf": true, ".eot": true, ".otf": true,
|
|
".mp3": true, ".mp4": true, ".mov": true, ".avi": true, ".webm": true,
|
|
".pyc": true, ".o": true, ".a": true,
|
|
}
|
|
|
|
// SearchCode does a client-side "git grep"-equivalent search. Gitea's REST API
|
|
// has no code-content-search endpoint — confirmed against a live 1.25.5
|
|
// instance's swagger spec (only /repos/search, /repos/issues/search,
|
|
// /topics/search etc exist). The web UI's OWN code search falls back to
|
|
// server-side `git grep` because no Repository Indexer is enabled on that
|
|
// instance, and that fallback is an HTML-only route, not JSON API. So this
|
|
// walks the tree, fetches text-like blobs (bounded by codeSearchMaxFiles /
|
|
// codeSearchMaxFileSize), and substring-matches q — case-insensitive, literal
|
|
// (not a regex, to keep behavior predictable and avoid a ReDoS surface from
|
|
// user-supplied input) — against file contents.
|
|
//
|
|
// Pagination is over the FULL sorted result set, recomputed on every call —
|
|
// there is no server-side index to page through incrementally, so requesting
|
|
// page 2 re-scans the tree. Acceptable for the repo sizes this targets; a real
|
|
// indexer (bleve/elasticsearch) enabled server-side would be the long-term
|
|
// fix, and is an infra decision, not something gitea-mcp controls.
|
|
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
|
if q == "" {
|
|
return nil, fmt.Errorf("q is required: %w", ErrValidation)
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if limit < 1 {
|
|
limit = 30
|
|
}
|
|
|
|
r, err := c.GetRepo(ctx, owner, repo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve repo: %w", err)
|
|
}
|
|
branch := r.DefaultBranch
|
|
if branch == "" {
|
|
branch = "main"
|
|
}
|
|
|
|
tree, err := c.GetTree(ctx, owner, repo, branch, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tree walk: %w", err)
|
|
}
|
|
|
|
qLower := strings.ToLower(q)
|
|
all := make([]CodeSearchHit, 0)
|
|
scanned := 0
|
|
for _, e := range tree.Tree {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
if e.Type != "blob" {
|
|
continue
|
|
}
|
|
if codeSearchBinaryExts[strings.ToLower(path.Ext(e.Path))] {
|
|
continue
|
|
}
|
|
if e.Size > codeSearchMaxFileSize {
|
|
continue
|
|
}
|
|
if scanned >= codeSearchMaxFiles {
|
|
break
|
|
}
|
|
scanned++
|
|
|
|
fc, ferr := c.GetFileContents(ctx, owner, repo, e.Path, branch)
|
|
if ferr != nil {
|
|
continue // vanished/unreadable between tree walk and read — skip, don't fail the whole search
|
|
}
|
|
decoded, derr := base64.StdEncoding.DecodeString(fc.Content)
|
|
if derr != nil {
|
|
continue
|
|
}
|
|
if bytes.IndexByte(decoded, 0) >= 0 {
|
|
continue // binary content the extension filter missed
|
|
}
|
|
|
|
content := string(decoded)
|
|
contentLower := strings.ToLower(content)
|
|
count := strings.Count(contentLower, qLower)
|
|
if count == 0 {
|
|
continue
|
|
}
|
|
|
|
idx := strings.Index(contentLower, qLower)
|
|
all = append(all, CodeSearchHit{
|
|
Path: e.Path,
|
|
Snippet: codeSearchSnippet(content, idx, len(q)),
|
|
HTMLURL: fmt.Sprintf("%s/%s/%s/src/branch/%s/%s", c.baseURL, owner, repo, branch, e.Path),
|
|
Score: float64(count),
|
|
})
|
|
}
|
|
|
|
sort.Slice(all, func(i, j int) bool {
|
|
if all[i].Score != all[j].Score {
|
|
return all[i].Score > all[j].Score
|
|
}
|
|
return all[i].Path < all[j].Path
|
|
})
|
|
|
|
start := (page - 1) * limit
|
|
if start >= len(all) {
|
|
return []CodeSearchHit{}, nil
|
|
}
|
|
end := start + limit
|
|
if end > len(all) {
|
|
end = len(all)
|
|
}
|
|
return all[start:end], nil
|
|
}
|
|
|
|
// codeSearchSnippet returns a short window of text centered on a match,
|
|
// trimmed to a single line-ish window so results read like a grep hit rather
|
|
// than a content dump.
|
|
func codeSearchSnippet(content string, idx, matchLen int) string {
|
|
const window = 60
|
|
start := idx - window
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := idx + matchLen + window
|
|
if end > len(content) {
|
|
end = len(content)
|
|
}
|
|
snippet := strings.ReplaceAll(content[start:end], "\n", " ")
|
|
return strings.TrimSpace(snippet)
|
|
}
|