diff --git a/docs/superpowers/plans/2026-05-06-gitops-agent-tools.md b/docs/superpowers/plans/2026-05-06-gitops-agent-tools.md new file mode 100644 index 0000000..7e3dc48 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-gitops-agent-tools.md @@ -0,0 +1,2450 @@ +# GitOps Agent Tools Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 9 new tools to the Gitea MCP server so an AI agent can drive a full GitOps loop autonomously — reading repo state, choosing a branching strategy, writing files, managing PRs, and tagging releases. + +**Architecture:** Each tool follows the existing pattern: one file in `internal/tools/`, one client method added to an existing domain file in `internal/gitea/`, registered in `cmd/gitea-mcp/main.go`. `repo_status` is the only tool that composes multiple client methods without adding a new one. All tests use `httptest.NewServer` mocks, table-driven where multiple cases are needed. + +**Tech Stack:** Go 1.26, `net/http/httptest`, `github.com/stretchr/testify`, `gitea.d-ma.be/mathias/gitea-mcp/internal/{gitea,tools,allowlist,registry}`. + +--- + +## File Map + +**New files:** +- `internal/gitea/tags.go` + `internal/gitea/tags_test.go` +- `internal/tools/branch_list.go` + `internal/tools/branch_list_test.go` +- `internal/tools/branch_delete.go` + `internal/tools/branch_delete_test.go` +- `internal/tools/branch_protection_get.go` + `internal/tools/branch_protection_get_test.go` +- `internal/tools/pr_list.go` + `internal/tools/pr_list_test.go` +- `internal/tools/pr_merge.go` + `internal/tools/pr_merge_test.go` +- `internal/tools/dir_list.go` + `internal/tools/dir_list_test.go` +- `internal/tools/file_delete.go` + `internal/tools/file_delete_test.go` +- `internal/tools/tag_create.go` + `internal/tools/tag_create_test.go` +- `internal/tools/repo_status.go` + `internal/tools/repo_status_test.go` + +**Modified files:** +- `internal/gitea/client.go` — add `DeleteJSONBody` +- `internal/gitea/files.go` — add `ListBranches`, `DeleteBranch`, `GetBranchProtection`, `DirEntry`, `ListContents`, `DeleteFileArgs`, `DeleteFile`, `BranchProtection`; add `"net/url"` import +- `internal/gitea/pulls.go` — add `ListPullRequests`, `MergePRArgs`, `MergePullRequest`; add `"net/url"` import +- `internal/gitea/files_test.go` — add tests for new file/branch methods +- `internal/gitea/pulls_test.go` — add tests for new PR methods +- `cmd/gitea-mcp/main.go` — register 9 new tools + +--- + +## Task 1: Add `DeleteJSONBody` to client + +**Files:** +- Modify: `internal/gitea/client.go` + +- [ ] **Step 1: Add the method** + +In `internal/gitea/client.go`, add after `DeleteJSON`: + +```go +func (c *Client) DeleteJSONBody(ctx context.Context, path string, body []byte) ([]byte, int, error) { + return c.do(ctx, http.MethodDelete, path, body) +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +go build ./internal/gitea/... +``` + +Expected: no output (success). + +- [ ] **Step 3: Commit** + +```bash +git add internal/gitea/client.go +git commit -m "feat(gitea): add DeleteJSONBody for delete-with-body requests" +``` + +--- + +## Task 2: `branch_list` + +**Files:** +- Modify: `internal/gitea/files.go`, `internal/gitea/files_test.go` +- Create: `internal/tools/branch_list.go`, `internal/tools/branch_list_test.go` + +- [ ] **Step 1: Write failing client test** + +Append to `internal/gitea/files_test.go`: + +```go +func TestListBranches(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/branches", r.URL.Path) + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "30", r.URL.Query().Get("limit")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"name":"main","commit":{"id":"abc","url":"http://example.com"}}, + {"name":"feat/x","commit":{"id":"def","url":"http://example.com"}} + ]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + branches, err := c.ListBranches(context.Background(), "o", "r", 0, 0) + require.NoError(t, err) + require.Len(t, branches, 2) + assert.Equal(t, "main", branches[0].Name) + assert.Equal(t, "abc", branches[0].Commit.ID) + assert.Equal(t, "feat/x", branches[1].Name) +} +``` + +- [ ] **Step 2: Run test — confirm it fails** + +```bash +go test ./internal/gitea/... -run TestListBranches -v +``` + +Expected: `FAIL — undefined: gitea.Client.ListBranches` + +- [ ] **Step 3: Implement client method** + +Add `"net/url"` to imports in `internal/gitea/files.go`, then append: + +```go +func (c *Client) ListBranches(ctx context.Context, owner, repo string, page, limit int) ([]Branch, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 30 + } + p := fmt.Sprintf("/api/v1/repos/%s/%s/branches?page=%d&limit=%d", owner, repo, page, limit) + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var branches []Branch + if err := json.Unmarshal(body, &branches); err != nil { + return nil, err + } + return branches, nil +} +``` + +- [ ] **Step 4: Run client test — confirm it passes** + +```bash +go test ./internal/gitea/... -run TestListBranches -v +``` + +Expected: `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/branch_list_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestBranchListReturnsNames(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"name":"main","commit":{"id":"abc","url":""}}, + {"name":"feat/x","commit":{"id":"def","url":""}} + ]`)) + })) + defer srv.Close() + + tool := tools.NewBranchList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`)) + require.NoError(t, err) + + var result []map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + require.Len(t, result, 2) + assert.Equal(t, "main", result[0]["name"]) + assert.Equal(t, "abc", result[0]["sha"]) + assert.Equal(t, "feat/x", result[1]["name"]) +} + +func TestBranchListAllowlistRejects(t *testing.T) { + tool := tools.NewBranchList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo"}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestBranchList -v +``` + +Expected: `FAIL — undefined: tools.NewBranchList` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/branch_list.go`: + +```go +package tools + +import ( + "context" + "encoding/json" + + "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 BranchList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewBranchList(c *gitea.Client, a *allowlist.Allowlist) *BranchList { + return &BranchList{c: c, a: a} +} + +func (t *BranchList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "branch_list", + Description: "List branches in a repository.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "page":{"type":"integer"}, + "limit":{"type":"integer"} + }, + "required":["owner","name"] + }`), + } +} + +type branchListArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args branchListArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + + branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, args.Page, capLimit(args.Limit, 30)) + if err != nil { + return nil, err + } + + result := make([]map[string]any, len(branches)) + for i, b := range branches { + result[i] = map[string]any{ + "name": b.Name, + "sha": b.Commit.ID, + } + } + return textOK(result) +} +``` + +- [ ] **Step 8: Run all branch_list tests — confirm they pass** + +```bash +go test ./internal/... -run "TestListBranches|TestBranchList" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/files.go internal/gitea/files_test.go \ + internal/tools/branch_list.go internal/tools/branch_list_test.go +git commit -m "feat(tools): branch_list" +``` + +--- + +## Task 3: `branch_delete` + +**Files:** +- Modify: `internal/gitea/files.go`, `internal/gitea/files_test.go` +- Create: `internal/tools/branch_delete.go`, `internal/tools/branch_delete_test.go` + +- [ ] **Step 1: Write failing client test** + +Append to `internal/gitea/files_test.go`: + +```go +func TestDeleteBranch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/branches/feat/x", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.DeleteBranch(context.Background(), "o", "r", "feat/x") + require.NoError(t, err) +} + +func TestDeleteBranchProtected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"branch is protected"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.DeleteBranch(context.Background(), "o", "r", "main") + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrPermissionDenied) +} +``` + +- [ ] **Step 2: Run test — confirm it fails** + +```bash +go test ./internal/gitea/... -run TestDeleteBranch -v +``` + +Expected: `FAIL — undefined: gitea.Client.DeleteBranch` + +- [ ] **Step 3: Implement client method** + +Append to `internal/gitea/files.go`: + +```go +func (c *Client) DeleteBranch(ctx context.Context, owner, repo, branch string) error { + p := fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", owner, repo, branch) + body, status, err := c.DeleteJSON(ctx, p) + if err != nil { + return err + } + return MapStatus(status, body) +} +``` + +- [ ] **Step 4: Run client tests — confirm they pass** + +```bash +go test ./internal/gitea/... -run TestDeleteBranch -v +``` + +Expected: all `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/branch_delete_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestBranchDeleteSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + tool := tools.NewBranchDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"feat/x"}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, true, result["deleted"]) + assert.Equal(t, "feat/x", result["branch"]) +} + +func TestBranchDeleteProtectedReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"branch is protected"}`)) + })) + defer srv.Close() + + tool := tools.NewBranchDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrPermissionDenied) +} + +func TestBranchDeleteAllowlistRejects(t *testing.T) { + tool := tools.NewBranchDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"feat/x"}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestBranchDelete -v +``` + +Expected: `FAIL — undefined: tools.NewBranchDelete` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/branch_delete.go`: + +```go +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 BranchDelete struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewBranchDelete(c *gitea.Client, a *allowlist.Allowlist) *BranchDelete { + return &BranchDelete{c: c, a: a} +} + +func (t *BranchDelete) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "branch_delete", + Description: "Delete a branch from a repository.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "branch":{"type":"string"} + }, + "required":["owner","name","branch"] + }`), + } +} + +type branchDeleteArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Branch string `json:"branch"` +} + +func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args branchDeleteArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Branch == "" { + return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation) + } + + if err := t.c.DeleteBranch(ctx, args.Owner, args.Name, args.Branch); err != nil { + return nil, err + } + + return textOK(map[string]any{ + "deleted": true, + "branch": args.Branch, + }) +} +``` + +- [ ] **Step 8: Run all branch_delete tests — confirm they pass** + +```bash +go test ./internal/... -run "TestDeleteBranch|TestBranchDelete" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/files.go internal/gitea/files_test.go \ + internal/tools/branch_delete.go internal/tools/branch_delete_test.go +git commit -m "feat(tools): branch_delete" +``` + +--- + +## Task 4: `branch_protection_get` + +**Files:** +- Modify: `internal/gitea/files.go`, `internal/gitea/files_test.go` +- Create: `internal/tools/branch_protection_get.go`, `internal/tools/branch_protection_get_test.go` + +- [ ] **Step 1: Write failing client tests** + +Append to `internal/gitea/files_test.go`: + +```go +func TestGetBranchProtectionFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/branch_protections/main", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "required_approvals": 2, + "push_whitelist_usernames": ["alice"], + "merge_whitelist_usernames": ["bob"] + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + bp, err := c.GetBranchProtection(context.Background(), "o", "r", "main") + require.NoError(t, err) + assert.True(t, bp.Protected) + assert.Equal(t, int64(2), bp.RequiredApprovals) + assert.Equal(t, []string{"alice"}, bp.PushWhitelist) + assert.Equal(t, []string{"bob"}, bp.MergeWhitelist) +} + +func TestGetBranchProtectionNotFoundReturnsUnprotected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + bp, err := c.GetBranchProtection(context.Background(), "o", "r", "feat/x") + require.NoError(t, err) + assert.False(t, bp.Protected) +} +``` + +- [ ] **Step 2: Run tests — confirm they fail** + +```bash +go test ./internal/gitea/... -run TestGetBranchProtection -v +``` + +Expected: `FAIL — undefined: gitea.Client.GetBranchProtection` + +- [ ] **Step 3: Implement client type and method** + +Append to `internal/gitea/files.go`: + +```go +type BranchProtection struct { + Protected bool `json:"-"` + RequiredApprovals int64 `json:"required_approvals"` + PushWhitelist []string `json:"push_whitelist_usernames"` + MergeWhitelist []string `json:"merge_whitelist_usernames"` +} + +func (c *Client) GetBranchProtection(ctx context.Context, owner, repo, branch string) (*BranchProtection, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, branch) + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if status == 404 { + return &BranchProtection{Protected: false}, nil + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var bp BranchProtection + if err := json.Unmarshal(body, &bp); err != nil { + return nil, err + } + bp.Protected = true + return &bp, nil +} +``` + +- [ ] **Step 4: Run client tests — confirm they pass** + +```bash +go test ./internal/gitea/... -run TestGetBranchProtection -v +``` + +Expected: all `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/branch_protection_get_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestBranchProtectionGetProtected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"required_approvals":1,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`)) + })) + defer srv.Close() + + tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, true, result["protected"]) + assert.Equal(t, float64(1), result["required_approvals"]) +} + +func TestBranchProtectionGetUnprotected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + defer srv.Close() + + tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"feat/x"}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, false, result["protected"]) +} + +func TestBranchProtectionGetAllowlistRejects(t *testing.T) { + tool := tools.NewBranchProtectionGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"main"}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestBranchProtectionGet -v +``` + +Expected: `FAIL — undefined: tools.NewBranchProtectionGet` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/branch_protection_get.go`: + +```go +package tools + +import ( + "context" + "encoding/json" + + "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 BranchProtectionGet struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewBranchProtectionGet(c *gitea.Client, a *allowlist.Allowlist) *BranchProtectionGet { + return &BranchProtectionGet{c: c, a: a} +} + +func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "branch_protection_get", + Description: "Get branch protection rules. Returns {protected:false} if no rule exists — never returns an error for unprotected branches.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "branch":{"type":"string"} + }, + "required":["owner","name","branch"] + }`), + } +} + +type branchProtectionGetArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Branch string `json:"branch"` +} + +func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args branchProtectionGetArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + + bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch) + if err != nil { + return nil, err + } + + return textOK(map[string]any{ + "protected": bp.Protected, + "required_approvals": bp.RequiredApprovals, + "push_whitelist": bp.PushWhitelist, + "merge_whitelist": bp.MergeWhitelist, + }) +} +``` + +- [ ] **Step 8: Run all branch_protection_get tests — confirm they pass** + +```bash +go test ./internal/... -run "TestGetBranchProtection|TestBranchProtectionGet" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/files.go internal/gitea/files_test.go \ + internal/tools/branch_protection_get.go internal/tools/branch_protection_get_test.go +git commit -m "feat(tools): branch_protection_get" +``` + +--- + +## Task 5: `pr_list` + +**Files:** +- Modify: `internal/gitea/pulls.go`, `internal/gitea/pulls_test.go` +- Create: `internal/tools/pr_list.go`, `internal/tools/pr_list_test.go` + +- [ ] **Step 1: Write failing client test** + +Append to `internal/gitea/pulls_test.go`: + +```go +func TestListPullRequests(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/pulls", r.URL.Path) + assert.Equal(t, "open", r.URL.Query().Get("state")) + assert.Equal(t, "feat/x", r.URL.Query().Get("head")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[` + pullFixture + `]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + prs, err := c.ListPullRequests(context.Background(), "o", "r", "open", "feat/x", 0, 0) + require.NoError(t, err) + require.Len(t, prs, 1) + assert.Equal(t, 7, prs[0].Number) + assert.Equal(t, "feat/x", prs[0].Head.Ref) +} +``` + +- [ ] **Step 2: Run test — confirm it fails** + +```bash +go test ./internal/gitea/... -run TestListPullRequests -v +``` + +Expected: `FAIL — undefined: gitea.Client.ListPullRequests` + +- [ ] **Step 3: Implement client method** + +Add `"net/url"` to imports in `internal/gitea/pulls.go`, then append: + +```go +func (c *Client) ListPullRequests(ctx context.Context, owner, repo, state, head string, page, limit int) ([]PullRequest, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 30 + } + p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls?state=%s&page=%d&limit=%d", + owner, repo, url.QueryEscape(state), page, limit) + if head != "" { + p += "&head=" + url.QueryEscape(head) + } + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var prs []PullRequest + if err := json.Unmarshal(body, &prs); err != nil { + return nil, err + } + return prs, nil +} +``` + +- [ ] **Step 4: Run client test — confirm it passes** + +```bash +go test ./internal/gitea/... -run TestListPullRequests -v +``` + +Expected: `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/pr_list_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestPRListReturnsOpenPRs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "open", r.URL.Query().Get("state")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{ + "number":7,"title":"Add feature X","html_url":"http://example.com/pulls/7", + "state":"open","draft":false, + "head":{"ref":"feat/x"},"base":{"ref":"main"} + }]`)) + })) + defer srv.Close() + + tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`)) + require.NoError(t, err) + + var result []map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + require.Len(t, result, 1) + assert.Equal(t, float64(7), result[0]["number"]) + assert.Equal(t, "feat/x", result[0]["head_branch"]) + assert.Equal(t, "main", result[0]["base_branch"]) +} + +func TestPRListDefaultsToOpen(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "open", r.URL.Query().Get("state")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`)) + require.NoError(t, err) + + var result []map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Empty(t, result) +} + +func TestPRListAllowlistRejects(t *testing.T) { + tool := tools.NewPRList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo"}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestPRList -v +``` + +Expected: `FAIL — undefined: tools.NewPRList` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/pr_list.go`: + +```go +package tools + +import ( + "context" + "encoding/json" + + "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 PRList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewPRList(c *gitea.Client, a *allowlist.Allowlist) *PRList { + return &PRList{c: c, a: a} +} + +func (t *PRList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "pr_list", + Description: "List pull requests. state: open (default), closed, or all. Optionally filter by head branch.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "state":{"type":"string","enum":["open","closed","all"]}, + "head":{"type":"string"}, + "page":{"type":"integer"}, + "limit":{"type":"integer"} + }, + "required":["owner","name"] + }`), + } +} + +type prListArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + State string `json:"state"` + Head string `json:"head"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args prListArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + state := args.State + if state == "" { + state = "open" + } + + prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, state, args.Head, args.Page, capLimit(args.Limit, 30)) + if err != nil { + return nil, err + } + + result := make([]map[string]any, len(prs)) + for i, pr := range prs { + result[i] = map[string]any{ + "number": pr.Number, + "title": pr.Title, + "state": pr.State, + "head_branch": pr.Head.Ref, + "base_branch": pr.Base.Ref, + "draft": pr.Draft, + "html_url": pr.HTMLURL, + } + } + return textOK(result) +} +``` + +- [ ] **Step 8: Run all pr_list tests — confirm they pass** + +```bash +go test ./internal/... -run "TestListPullRequests|TestPRList" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/pulls.go internal/gitea/pulls_test.go \ + internal/tools/pr_list.go internal/tools/pr_list_test.go +git commit -m "feat(tools): pr_list" +``` + +--- + +## Task 6: `pr_merge` + +**Files:** +- Modify: `internal/gitea/pulls.go`, `internal/gitea/pulls_test.go` +- Create: `internal/tools/pr_merge.go`, `internal/tools/pr_merge_test.go` + +- [ ] **Step 1: Write failing client tests** + +Append to `internal/gitea/pulls_test.go`: + +```go +func TestMergePullRequestSuccess(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/pulls/7/merge", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "squash"}) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "squash", payload["Do"]) +} + +func TestMergePullRequestConflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message":"merge conflict"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "merge"}) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrConflict) +} +``` + +- [ ] **Step 2: Run tests — confirm they fail** + +```bash +go test ./internal/gitea/... -run TestMergePullRequest -v +``` + +Expected: `FAIL — undefined: gitea.Client.MergePullRequest` + +- [ ] **Step 3: Implement client type and method** + +Append to `internal/gitea/pulls.go`: + +```go +type MergePRArgs struct { + Do string `json:"Do"` + Title string `json:"merge_message_title,omitempty"` + Body string `json:"merge_message_field,omitempty"` +} + +func (c *Client) MergePullRequest(ctx context.Context, owner, repo string, index int, args MergePRArgs) error { + p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) + payload, err := json.Marshal(args) + if err != nil { + return err + } + body, status, err := c.PostJSON(ctx, p, payload) + if err != nil { + return err + } + return MapStatus(status, body) +} +``` + +- [ ] **Step 4: Run client tests — confirm they pass** + +```bash +go test ./internal/gitea/... -run TestMergePullRequest -v +``` + +Expected: all `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/pr_merge_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "io" + "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 TestPRMergeSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, true, result["merged"]) +} + +func TestPRMergeDefaultsToMergeStyle(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`)) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "merge", payload["Do"]) +} + +func TestPRMergeConflictReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message":"merge conflict"}`)) + })) + defer srv.Close() + + tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrConflict) +} + +func TestPRMergeAllowlistRejects(t *testing.T) { + tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestPRMerge -v +``` + +Expected: `FAIL — undefined: tools.NewPRMerge` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/pr_merge.go`: + +```go +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 PRMerge struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewPRMerge(c *gitea.Client, a *allowlist.Allowlist) *PRMerge { + return &PRMerge{c: c, a: a} +} + +func (t *PRMerge) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "pr_merge", + Description: "Merge a pull request. style: merge (default), squash, or rebase.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "index":{"type":"integer","minimum":1}, + "style":{"type":"string","enum":["merge","squash","rebase"]}, + "merge_message_title":{"type":"string"}, + "merge_message_field":{"type":"string"} + }, + "required":["owner","name","index"] + }`), + } +} + +type prMergeArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Index int `json:"index"` + Style string `json:"style"` + Title string `json:"merge_message_title"` + Body string `json:"merge_message_field"` +} + +func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args prMergeArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Index < 1 { + return nil, fmt.Errorf("index must be >= 1: %w", gitea.ErrValidation) + } + + style := args.Style + if style == "" { + style = "merge" + } + + if err := t.c.MergePullRequest(ctx, args.Owner, args.Name, args.Index, gitea.MergePRArgs{ + Do: style, + Title: args.Title, + Body: args.Body, + }); err != nil { + return nil, err + } + + return textOK(map[string]any{"merged": true}) +} +``` + +- [ ] **Step 8: Run all pr_merge tests — confirm they pass** + +```bash +go test ./internal/... -run "TestMergePullRequest|TestPRMerge" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/pulls.go internal/gitea/pulls_test.go \ + internal/tools/pr_merge.go internal/tools/pr_merge_test.go +git commit -m "feat(tools): pr_merge" +``` + +--- + +## Task 7: `dir_list` + +**Files:** +- Modify: `internal/gitea/files.go`, `internal/gitea/files_test.go` +- Create: `internal/tools/dir_list.go`, `internal/tools/dir_list_test.go` + +- [ ] **Step 1: Write failing client tests** + +Append to `internal/gitea/files_test.go`: + +```go +func TestListContentsDirectory(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/contents/src", r.URL.Path) + assert.Equal(t, "main", r.URL.Query().Get("ref")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"name":"main.go","path":"src/main.go","type":"file","sha":"abc","size":100}, + {"name":"lib","path":"src/lib","type":"dir","sha":"def","size":0} + ]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + entries, err := c.ListContents(context.Background(), "o", "r", "src", "main") + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "main.go", entries[0].Name) + assert.Equal(t, "file", entries[0].Type) + assert.Equal(t, "lib", entries[1].Name) + assert.Equal(t, "dir", entries[1].Type) +} + +func TestListContentsOnFileReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"main.go","sha":"abc","size":100,"content":"","encoding":"base64"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + _, err := c.ListContents(context.Background(), "o", "r", "main.go", "") + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} +``` + +- [ ] **Step 2: Run tests — confirm they fail** + +```bash +go test ./internal/gitea/... -run TestListContents -v +``` + +Expected: `FAIL — undefined: gitea.Client.ListContents` + +- [ ] **Step 3: Implement client type and method** + +Append to `internal/gitea/files.go` (the `"net/url"` import was already added in Task 2): + +```go +type DirEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Sha string `json:"sha"` + Size int64 `json:"size"` +} + +func (c *Client) ListContents(ctx context.Context, owner, repo, path, ref string) ([]DirEntry, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path) + if ref != "" { + p += "?ref=" + url.QueryEscape(ref) + } + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + if len(body) > 0 && body[0] == '{' { + return nil, fmt.Errorf("path is a file, not a directory — use file_read: %w", ErrValidation) + } + var entries []DirEntry + if err := json.Unmarshal(body, &entries); err != nil { + return nil, err + } + return entries, nil +} +``` + +- [ ] **Step 4: Run client tests — confirm they pass** + +```bash +go test ./internal/gitea/... -run TestListContents -v +``` + +Expected: all `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/dir_list_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestDirListReturnsEntries(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/contents/src", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"name":"main.go","path":"src/main.go","type":"file","sha":"abc","size":512}, + {"name":"util","path":"src/util","type":"dir","sha":"def","size":0} + ]`)) + })) + defer srv.Close() + + tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":"src"}`)) + require.NoError(t, err) + + var result []map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + require.Len(t, result, 2) + assert.Equal(t, "main.go", result[0]["name"]) + assert.Equal(t, "file", result[0]["type"]) + assert.Equal(t, "util", result[1]["name"]) + assert.Equal(t, "dir", result[1]["type"]) +} + +func TestDirListRootPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/contents/", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":""}`)) + require.NoError(t, err) + + var result []map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Empty(t, result) +} + +func TestDirListOnFileReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"README.md","sha":"abc","size":10,"content":"","encoding":"base64"}`)) + })) + defer srv.Close() + + tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":"README.md"}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +func TestDirListAllowlistRejects(t *testing.T) { + tool := tools.NewDirList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","path":""}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestDirList -v +``` + +Expected: `FAIL — undefined: tools.NewDirList` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/dir_list.go`: + +```go +package tools + +import ( + "context" + "encoding/json" + + "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 DirList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewDirList(c *gitea.Client, a *allowlist.Allowlist) *DirList { + return &DirList{c: c, a: a} +} + +func (t *DirList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "dir_list", + Description: "List directory contents in a repository. Use empty path for repo root. Returns name, path, type (file/dir/symlink), sha, size per entry.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "path":{"type":"string"}, + "ref":{"type":"string"} + }, + "required":["owner","name"] + }`), + } +} + +type dirListArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Path string `json:"path"` + Ref string `json:"ref"` +} + +func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args dirListArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + + entries, err := t.c.ListContents(ctx, args.Owner, args.Name, args.Path, args.Ref) + if err != nil { + return nil, err + } + + result := make([]map[string]any, len(entries)) + for i, e := range entries { + result[i] = map[string]any{ + "name": e.Name, + "path": e.Path, + "type": e.Type, + "sha": e.Sha, + "size": e.Size, + } + } + return textOK(result) +} +``` + +- [ ] **Step 8: Run all dir_list tests — confirm they pass** + +```bash +go test ./internal/... -run "TestListContents|TestDirList" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/files.go internal/gitea/files_test.go \ + internal/tools/dir_list.go internal/tools/dir_list_test.go +git commit -m "feat(tools): dir_list" +``` + +--- + +## Task 8: `file_delete` + +**Files:** +- Modify: `internal/gitea/files.go`, `internal/gitea/files_test.go` +- Create: `internal/tools/file_delete.go`, `internal/tools/file_delete_test.go` + +- [ ] **Step 1: Write failing client test** + +Append to `internal/gitea/files_test.go`: + +```go +func TestDeleteFile(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/contents/src/old.go", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "content":null, + "commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"} + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + result, err := c.DeleteFile(context.Background(), "o", "r", "src/old.go", gitea.DeleteFileArgs{ + Branch: "main", + Message: "remove old.go", + Sha: "blobsha", + }) + require.NoError(t, err) + assert.Equal(t, "cmt1", result.Commit.Sha) + + var payload map[string]string + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "main", payload["branch"]) + assert.Equal(t, "remove old.go", payload["message"]) + assert.Equal(t, "blobsha", payload["sha"]) +} +``` + +Note: this test uses `io.ReadAll` — ensure `"io"` is in the imports of `files_test.go`. + +- [ ] **Step 2: Run test — confirm it fails** + +```bash +go test ./internal/gitea/... -run TestDeleteFile -v +``` + +Expected: `FAIL — undefined: gitea.Client.DeleteFile` + +- [ ] **Step 3: Implement client type and method** + +Append to `internal/gitea/files.go`: + +```go +type DeleteFileArgs struct { + Branch string `json:"branch"` + Message string `json:"message"` + Sha string `json:"sha"` +} + +func (c *Client) DeleteFile(ctx context.Context, owner, repo, path string, args DeleteFileArgs) (*FileWriteResult, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path) + payload, err := json.Marshal(args) + if err != nil { + return nil, err + } + body, status, err := c.DeleteJSONBody(ctx, p, payload) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var out FileWriteResult + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return &out, nil +} +``` + +- [ ] **Step 4: Run client test — confirm it passes** + +```bash +go test ./internal/gitea/... -run TestDeleteFile -v +``` + +Expected: `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/file_delete_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestFileDeleteSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`)) + })) + defer srv.Close() + + tool := tools.NewFileDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"owner","name":"repo","path":"src/old.go", + "branch":"main","message":"remove old.go","sha":"blobsha" + }`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "cmt1", result["commit_sha"]) +} + +func TestFileDeleteRequiresSha(t *testing.T) { + tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"owner","name":"repo","path":"f.go","branch":"main","message":"rm" + }`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +func TestFileDeleteAllowlistRejects(t *testing.T) { + tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"evil","name":"repo","path":"f.go","branch":"main","message":"rm","sha":"abc" + }`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestFileDelete -v +``` + +Expected: `FAIL — undefined: tools.NewFileDelete` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/file_delete.go`: + +```go +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 FileDelete struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewFileDelete(c *gitea.Client, a *allowlist.Allowlist) *FileDelete { + return &FileDelete{c: c, a: a} +} + +func (t *FileDelete) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "file_delete", + Description: "Delete a file from a repository branch. sha is the current blob SHA (from file_read).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "path":{"type":"string"}, + "branch":{"type":"string"}, + "message":{"type":"string"}, + "sha":{"type":"string"} + }, + "required":["owner","name","path","branch","message","sha"] + }`), + } +} + +type fileDeleteArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Message string `json:"message"` + Sha string `json:"sha"` +} + +func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args fileDeleteArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Sha == "" { + return nil, fmt.Errorf("sha is required: %w", gitea.ErrValidation) + } + if args.Message == "" { + return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation) + } + + result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{ + Branch: args.Branch, + Message: args.Message, + Sha: args.Sha, + }) + if err != nil { + return nil, err + } + + return textOK(map[string]any{ + "commit_sha": result.Commit.Sha, + "html_url": result.Commit.HTMLURL, + }) +} +``` + +- [ ] **Step 8: Run all file_delete tests — confirm they pass** + +```bash +go test ./internal/... -run "TestDeleteFile|TestFileDelete" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/files.go internal/gitea/files_test.go \ + internal/tools/file_delete.go internal/tools/file_delete_test.go +git commit -m "feat(tools): file_delete" +``` + +--- + +## Task 9: `tag_create` + +**Files:** +- Create: `internal/gitea/tags.go`, `internal/gitea/tags_test.go` +- Create: `internal/tools/tag_create.go`, `internal/tools/tag_create_test.go` + +- [ ] **Step 1: Write failing client test** + +Create `internal/gitea/tags_test.go`: + +```go +package gitea_test + +import ( + "context" + "encoding/json" + "io" + "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 TestCreateTag(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/tags", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "name":"v1.0.0", + "id":"tagsha", + "message":"release", + "commit":{"sha":"cmt1","url":"http://example.com/commit/cmt1"} + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + tag, err := c.CreateTag(context.Background(), "o", "r", gitea.CreateTagArgs{ + TagName: "v1.0.0", + Target: "main", + Message: "release", + }) + require.NoError(t, err) + assert.Equal(t, "v1.0.0", tag.Name) + assert.Equal(t, "cmt1", tag.Commit.Sha) + + var payload map[string]string + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "v1.0.0", payload["tag_name"]) + assert.Equal(t, "main", payload["target"]) + assert.Equal(t, "release", payload["message"]) +} +``` + +- [ ] **Step 2: Run test — confirm it fails** + +```bash +go test ./internal/gitea/... -run TestCreateTag -v +``` + +Expected: `FAIL — undefined: gitea.Client.CreateTag` + +- [ ] **Step 3: Implement `internal/gitea/tags.go`** + +```go +package gitea + +import ( + "context" + "encoding/json" + "fmt" +) + +type CreateTagArgs struct { + TagName string `json:"tag_name"` + Target string `json:"target"` + Message string `json:"message,omitempty"` +} + +type Tag struct { + Name string `json:"name"` + ID string `json:"id"` + Message string `json:"message"` + Commit struct { + Sha string `json:"sha"` + } `json:"commit"` +} + +func (c *Client) CreateTag(ctx context.Context, owner, repo string, args CreateTagArgs) (*Tag, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/tags", owner, repo) + payload, err := json.Marshal(args) + if err != nil { + return nil, err + } + body, status, err := c.PostJSON(ctx, p, payload) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var tag Tag + if err := json.Unmarshal(body, &tag); err != nil { + return nil, err + } + return &tag, nil +} +``` + +- [ ] **Step 4: Run client test — confirm it passes** + +```bash +go test ./internal/gitea/... -run TestCreateTag -v +``` + +Expected: `PASS` + +- [ ] **Step 5: Write failing tool test** + +Create `internal/tools/tag_create_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestTagCreateSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/tags", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "name":"v2.0.0","id":"tagsha", + "commit":{"sha":"cmt1","url":""} + }`)) + })) + defer srv.Close() + + tool := tools.NewTagCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"owner","name":"repo","tag":"v2.0.0","target":"main" + }`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "v2.0.0", result["tag"]) + assert.Equal(t, "cmt1", result["commit_sha"]) +} + +func TestTagCreateRequiresTag(t *testing.T) { + tool := tools.NewTagCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","target":"main"}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +func TestTagCreateAllowlistRejects(t *testing.T) { + tool := tools.NewTagCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","tag":"v1.0.0","target":"main"}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 6: Run tool test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestTagCreate -v +``` + +Expected: `FAIL — undefined: tools.NewTagCreate` + +- [ ] **Step 7: Implement tool** + +Create `internal/tools/tag_create.go`: + +```go +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 TagCreate struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewTagCreate(c *gitea.Client, a *allowlist.Allowlist) *TagCreate { + return &TagCreate{c: c, a: a} +} + +func (t *TagCreate) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "tag_create", + Description: "Create a tag pointing at a branch or commit SHA. Add a message to create an annotated tag.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "tag":{"type":"string"}, + "target":{"type":"string"}, + "message":{"type":"string"} + }, + "required":["owner","name","tag","target"] + }`), + } +} + +type tagCreateArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Tag string `json:"tag"` + Target string `json:"target"` + Message string `json:"message"` +} + +func (t *TagCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args tagCreateArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Tag == "" { + return nil, fmt.Errorf("tag is required: %w", gitea.ErrValidation) + } + if args.Target == "" { + return nil, fmt.Errorf("target is required: %w", gitea.ErrValidation) + } + + tag, err := t.c.CreateTag(ctx, args.Owner, args.Name, gitea.CreateTagArgs{ + TagName: args.Tag, + Target: args.Target, + Message: args.Message, + }) + if err != nil { + return nil, err + } + + return textOK(map[string]any{ + "tag": tag.Name, + "commit_sha": tag.Commit.Sha, + }) +} +``` + +- [ ] **Step 8: Run all tag_create tests — confirm they pass** + +```bash +go test ./internal/... -run "TestCreateTag|TestTagCreate" -v +``` + +Expected: all `PASS` + +- [ ] **Step 9: Commit** + +```bash +git add internal/gitea/tags.go internal/gitea/tags_test.go \ + internal/tools/tag_create.go internal/tools/tag_create_test.go +git commit -m "feat(tools): tag_create" +``` + +--- + +## Task 10: `repo_status` + +**Files:** +- Create: `internal/tools/repo_status.go`, `internal/tools/repo_status_test.go` + +No new client methods needed — composes `DefaultBranch`, `ListBranches`, `ListPullRequests`, `GetBranchProtection`. + +- [ ] **Step 1: Write failing tool test** + +Create `internal/tools/repo_status_test.go`: + +```go +package tools_test + +import ( + "context" + "encoding/json" + "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 TestRepoStatusComposesThreeEndpoints(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"name":"main","commit":{"id":"abc","url":""}}, + {"name":"feat/x","commit":{"id":"def","url":""}} + ]`)) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "open", r.URL.Query().Get("state")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{ + "number":3,"title":"My PR","html_url":"http://example.com/pulls/3", + "state":"open","draft":false, + "head":{"ref":"feat/x"},"base":{"ref":"main"} + }]`)) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"required_approvals":1,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + + branches := result["branches"].([]any) + assert.Len(t, branches, 2) + + openPRs := result["open_prs"].([]any) + assert.Len(t, openPRs, 1) + assert.Equal(t, float64(3), openPRs[0].(map[string]any)["number"]) + + protection := result["protection"].(map[string]any) + assert.Equal(t, true, protection["protected"]) + assert.Equal(t, float64(1), protection["required_approvals"]) +} + +func TestRepoStatusUnprotectedBranch(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","url":""}}]`)) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + protection := result["protection"].(map[string]any) + assert.Equal(t, false, protection["protected"]) +} + +func TestRepoStatusAllowlistRejects(t *testing.T) { + tool := tools.NewRepoStatus(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"main"}`)) + require.Error(t, err) +} +``` + +- [ ] **Step 2: Run test — confirm it fails** + +```bash +go test ./internal/tools/... -run TestRepoStatus -v +``` + +Expected: `FAIL — undefined: tools.NewRepoStatus` + +- [ ] **Step 3: Implement tool** + +Create `internal/tools/repo_status.go`: + +```go +package tools + +import ( + "context" + "encoding/json" + + "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 RepoStatus struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoStatus(c *gitea.Client, a *allowlist.Allowlist) *RepoStatus { + return &RepoStatus{c: c, a: a} +} + +func (t *RepoStatus) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_status", + Description: "Get repo state in one call: all branches, open PRs, and protection rules for a target branch. Use this first to decide whether to use feature-branch or trunk-based development.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "branch":{"type":"string"} + }, + "required":["owner","name"] + }`), + } +} + +type repoStatusArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Branch string `json:"branch"` +} + +func (t *RepoStatus) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoStatusArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + + branch := args.Branch + if branch == "" { + var err error + branch, err = t.c.DefaultBranch(ctx, args.Owner, args.Name) + if err != nil { + return nil, err + } + } + + branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, 1, 50) + if err != nil { + return nil, err + } + + prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, "open", "", 1, 50) + if err != nil { + return nil, err + } + + bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, branch) + if err != nil { + return nil, err + } + + branchList := make([]map[string]any, len(branches)) + for i, b := range branches { + branchList[i] = map[string]any{"name": b.Name, "sha": b.Commit.ID} + } + + prList := make([]map[string]any, len(prs)) + for i, pr := range prs { + prList[i] = map[string]any{ + "number": pr.Number, + "title": pr.Title, + "state": pr.State, + "head_branch": pr.Head.Ref, + "base_branch": pr.Base.Ref, + "draft": pr.Draft, + "html_url": pr.HTMLURL, + } + } + + return textOK(map[string]any{ + "branches": branchList, + "open_prs": prList, + "protection": map[string]any{ + "protected": bp.Protected, + "required_approvals": bp.RequiredApprovals, + "push_whitelist": bp.PushWhitelist, + "merge_whitelist": bp.MergeWhitelist, + }, + }) +} +``` + +- [ ] **Step 4: Run all repo_status tests — confirm they pass** + +```bash +go test ./internal/tools/... -run TestRepoStatus -v +``` + +Expected: all `PASS` + +- [ ] **Step 5: Commit** + +```bash +git add internal/tools/repo_status.go internal/tools/repo_status_test.go +git commit -m "feat(tools): repo_status" +``` + +--- + +## Task 11: Register all tools + final verification + +**Files:** +- Modify: `cmd/gitea-mcp/main.go` + +- [ ] **Step 1: Register the 9 new tools in main.go** + +In `cmd/gitea-mcp/main.go`, replace the `reg.Register` block with: + +```go +reg.Register(tools.NewRepoList(giteaClient, ownerAllow)) +reg.Register(tools.NewRepoGet(giteaClient, ownerAllow)) +reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow)) +reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow)) +reg.Register(tools.NewFileRead(giteaClient, ownerAllow)) +reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow)) +reg.Register(tools.NewFileDelete(giteaClient, ownerAllow)) +reg.Register(tools.NewDirList(giteaClient, ownerAllow)) +reg.Register(tools.NewBranchList(giteaClient, ownerAllow)) +reg.Register(tools.NewBranchDelete(giteaClient, ownerAllow)) +reg.Register(tools.NewBranchProtectionGet(giteaClient, ownerAllow)) +reg.Register(tools.NewPRCreate(giteaClient, ownerAllow)) +reg.Register(tools.NewPRGet(giteaClient, ownerAllow)) +reg.Register(tools.NewPRList(giteaClient, ownerAllow)) +reg.Register(tools.NewPRMerge(giteaClient, ownerAllow)) +reg.Register(tools.NewPRComment(giteaClient, ownerAllow)) +reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow)) +reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL)) +reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow)) +reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow)) +reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow)) +reg.Register(tools.NewIssueComment(giteaClient, ownerAllow)) +reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web")) +reg.Register(tools.NewTagCreate(giteaClient, ownerAllow)) +``` + +- [ ] **Step 2: Build** + +```bash +go build ./... +``` + +Expected: no output (success). + +- [ ] **Step 3: Run full test suite** + +```bash +go test ./... -race -count=1 +``` + +Expected: all packages `ok`, zero failures. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/gitea-mcp/main.go +git commit -m "feat(main): register 9 new GitOps tools" +```