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) }