fix(code_search): replace the fantasy REST endpoint with a real client-side grep
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>
This commit is contained in:
@@ -2,8 +2,10 @@ package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -16,22 +18,76 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCodeSearchSingleRepo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
||||
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
|
||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
||||
// multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents
|
||||
// for a set of repos — the real endpoints code_search's underlying SearchCode
|
||||
// now uses (Gitea's REST API has no code-content-search endpoint; see
|
||||
// internal/gitea/code_search.go's doc comment).
|
||||
type multiRepoSearchFake struct {
|
||||
owner string
|
||||
repos []string // ListRepos response, in this order
|
||||
files map[string]map[string]string // repo -> path -> content
|
||||
fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure)
|
||||
}
|
||||
|
||||
func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"data":[{
|
||||
"path":"internal/gitea/repos.go",
|
||||
"snippet":"func (c *Client) ListRepos",
|
||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
|
||||
"score":3.0
|
||||
}],
|
||||
"ok":true
|
||||
}`))
|
||||
}))
|
||||
p := r.URL.Path
|
||||
|
||||
for _, repo := range f.repos {
|
||||
base := "/api/v1/repos/" + f.owner + "/" + repo
|
||||
switch {
|
||||
case p == base && f.fail[repo]:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||
return
|
||||
case p == base:
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)
|
||||
return
|
||||
case strings.HasPrefix(p, base+"/git/trees/"):
|
||||
var entries []string
|
||||
for path := range f.files[repo] {
|
||||
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||
return
|
||||
case strings.HasPrefix(p, base+"/contents/"):
|
||||
path := strings.TrimPrefix(p, base+"/contents/")
|
||||
content, ok := f.files[repo][path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
return
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if p == "/api/v1/users/"+f.owner+"/repos" {
|
||||
var entries []string
|
||||
for _, repo := range f.repos {
|
||||
entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ","))
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeSearchSingleRepo(t *testing.T) {
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"infra"},
|
||||
files: map[string]map[string]string{
|
||||
"infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"},
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
@@ -50,7 +106,7 @@ func TestCodeSearchSingleRepo(t *testing.T) {
|
||||
require.Len(t, result.Results, 1)
|
||||
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
||||
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
|
||||
assert.Contains(t, result.Results[0].Snippet, "ListRepos")
|
||||
}
|
||||
|
||||
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
||||
@@ -67,22 +123,15 @@ func TestCodeSearchRequiresQ(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/infra/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/gitea-mcp/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"infra", "gitea-mcp"},
|
||||
files: map[string]map[string]string{
|
||||
"infra": {"main.go": "this is an infra hit\n"},
|
||||
"gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"},
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
@@ -110,23 +159,15 @@ func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/infra/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/broken/search":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"infra", "broken"},
|
||||
files: map[string]map[string]string{
|
||||
"infra": {"main.go": "this is an infra hit\n"},
|
||||
},
|
||||
fail: map[string]bool{"broken": true},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
@@ -134,9 +175,11 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct{ Repo string `json:"repo"` } `json:"results"`
|
||||
Partial bool `json:"partial"`
|
||||
PartialRepos []string `json:"partial_repos"`
|
||||
Results []struct {
|
||||
Repo string `json:"repo"`
|
||||
} `json:"results"`
|
||||
Partial bool `json:"partial"`
|
||||
PartialRepos []string `json:"partial_repos"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.True(t, result.Partial)
|
||||
@@ -147,41 +190,31 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
|
||||
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/alpha/search":
|
||||
// low score
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/beta/search":
|
||||
// high score
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"alpha", "beta"},
|
||||
files: map[string]map[string]string{
|
||||
"alpha": {"a.go": "one high here"}, // 1 occurrence => score 1
|
||||
"beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Repo string `json:"repo"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
require.Len(t, result.Results, 2)
|
||||
// First result must be the high-score one
|
||||
assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first")
|
||||
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
||||
"expected results sorted by score desc, got %v then %v",
|
||||
result.Results[0].Score, result.Results[1].Score)
|
||||
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
|
||||
"expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user