From e4a9d058f0e503be8e4b3492671719a9a7e8e19b Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 4 May 2026 22:47:33 +0200 Subject: [PATCH] feat(tools): code_search (single-repo) Adds SearchCode to gitea.Client and code_search MCP tool for single-repo code search via GET /api/v1/repos/{owner}/{repo}/search?type=code. Fan-out placeholder returns ErrValidation (lands in 7.3). Co-Authored-By: Claude Sonnet 4.6 --- cmd/gitea-mcp/main.go | 1 + internal/gitea/code_search.go | 43 ++++++++++++ internal/gitea/code_search_test.go | 39 +++++++++++ internal/tools/code_search.go | 104 +++++++++++++++++++++++++++++ internal/tools/code_search_test.go | 73 ++++++++++++++++++++ 5 files changed, 260 insertions(+) create mode 100644 internal/gitea/code_search.go create mode 100644 internal/gitea/code_search_test.go create mode 100644 internal/tools/code_search.go create mode 100644 internal/tools/code_search_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index f5ab79f..c70c0a0 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -36,6 +36,7 @@ func main() { reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL)) reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow)) reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow)) + reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow)) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/code_search.go b/internal/gitea/code_search.go new file mode 100644 index 0000000..01021db --- /dev/null +++ b/internal/gitea/code_search.go @@ -0,0 +1,43 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +type CodeSearchHit struct { + Path string `json:"path"` + Snippet string `json:"snippet"` + HTMLURL string `json:"html_url"` + Score float64 `json:"score,omitempty"` +} + +type codeSearchEnvelope struct { + Data []CodeSearchHit `json:"data"` + OK bool `json:"ok"` +} + +func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 30 + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d", + owner, repo, url.QueryEscape(q), page, limit) + body, status, err := c.GetJSON(ctx, path) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var env codeSearchEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, err + } + return env.Data, nil +} diff --git a/internal/gitea/code_search_test.go b/internal/gitea/code_search_test.go new file mode 100644 index 0000000..2dc908a --- /dev/null +++ b/internal/gitea/code_search_test.go @@ -0,0 +1,39 @@ +package gitea_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSearchCode(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, "SearchCode", r.URL.Query().Get("q")) + assert.Equal(t, "code", r.URL.Query().Get("type")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data":[{ + "path":"internal/gitea/code_search.go", + "snippet":"func (c *Client) SearchCode", + "html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go", + "score":2.5 + }], + "ok":true + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30) + require.NoError(t, err) + require.Len(t, hits, 1) + assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path) + assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet) + assert.InDelta(t, 2.5, hits[0].Score, 0.001) +} diff --git a/internal/tools/code_search.go b/internal/tools/code_search.go new file mode 100644 index 0000000..39c3795 --- /dev/null +++ b/internal/tools/code_search.go @@ -0,0 +1,104 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type CodeSearch struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewCodeSearch(c *gitea.Client, a *allowlist.Allowlist) *CodeSearch { + return &CodeSearch{c: c, a: a} +} + +func (t *CodeSearch) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "code_search", + Description: "Search code across one repo or fan out across an owner's repos.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "q":{"type":"string"}, + "owner":{"type":"string"}, + "repo":{"type":"string"}, + "page":{"type":"integer","minimum":1}, + "limit":{"type":"integer","minimum":1,"maximum":50} + }, + "required":["q","owner"] + }`), + } +} + +type codeSearchArgs struct { + Q string `json:"q"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +type codeSearchResult struct { + Repo string `json:"repo"` + Path string `json:"path"` + Snippet string `json:"snippet"` + Score float64 `json:"score"` + HTMLURL string `json:"html_url"` +} + +func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args codeSearchArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if args.Q == "" { + return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation) + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Page < 1 { + args.Page = 1 + } + if args.Limit < 1 || args.Limit > 50 { + args.Limit = 30 + } + + if args.Repo == "" { + // Phase 7.2: leave fan-out unimplemented — just error out for now. + return nil, fmt.Errorf("repo is required for single-repo search (org-wide fan-out lands in 7.3): %w", gitea.ErrValidation) + } + + hits, err := t.c.SearchCode(ctx, args.Owner, args.Repo, args.Q, args.Page, args.Limit) + if err != nil { + return nil, err + } + + results := make([]codeSearchResult, 0, len(hits)) + repoFull := args.Owner + "/" + args.Repo + for _, h := range hits { + score := h.Score + if score == 0 { + score = 1.0 + } + results = append(results, codeSearchResult{ + Repo: repoFull, + Path: h.Path, + Snippet: h.Snippet, + Score: score, + HTMLURL: h.HTMLURL, + }) + } + out := map[string]any{"results": results} + if len(hits) == args.Limit { + out["next_page"] = args.Page + 1 + } + return textOK(out) +} diff --git a/internal/tools/code_search_test.go b/internal/tools/code_search_test.go new file mode 100644 index 0000000..4c541f4 --- /dev/null +++ b/internal/tools/code_search_test.go @@ -0,0 +1,73 @@ +package tools_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "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")) + 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 + }`)) + })) + defer srv.Close() + + tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"ListRepos","owner":"mathias","repo":"infra"}`)) + require.NoError(t, err) + + var result struct { + Results []struct { + Repo string `json:"repo"` + Path string `json:"path"` + Snippet string `json:"snippet"` + Score float64 `json:"score"` + } `json:"results"` + } + require.NoError(t, json.Unmarshal(out, &result)) + 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) +} + +func TestCodeSearchAllowlistRejects(t *testing.T) { + tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"q":"foo","owner":"evil","repo":"infra"}`)) + require.Error(t, err) +} + +func TestCodeSearchRequiresQ(t *testing.T) { + tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`)) + require.Error(t, err) + assert.True(t, errors.Is(err, gitea.ErrValidation)) +} + +func TestCodeSearchFanOutNotYetImplemented(t *testing.T) { + tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"q":"foo","owner":"mathias"}`)) + require.Error(t, err) + assert.True(t, errors.Is(err, gitea.ErrValidation)) +}