diff --git a/.aider.conventions.md b/.aider.conventions.md index d1730a9..230796d 100644 --- a/.aider.conventions.md +++ b/.aider.conventions.md @@ -49,9 +49,10 @@ These rules apply to every task across every project, regardless of harness. | Build | Task (taskfile.dev) | Make | — | | Containers | Docker Compose (dev), k3s (prod) | — | — | | DB | PostgreSQL + sqlc | SQLite | — | -| Search | Qdrant (vector), BM25 | — | — | +| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | +| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -63,7 +64,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -71,7 +72,7 @@ Three machines on Tailscale: | Machine | Role | Key specs | |---------|------|-----------| -| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector | | iguana | Services, builds | M2 Ultra Mac | | flamingo | Daily driver, edge | Mac mini, ~/dev is here | @@ -251,3 +252,67 @@ When acting as a coding agent on this project: 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM + +## Current sprint — gitea-mcp v0.2 (2026-05-14) + +### Context +This sprint implements new MCP tools needed for `hyperguild new-project` — +the automated project creation flow triggered from claude.ai. See brain knowledge +nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` +for full background. + +### Issues to implement (priority order) + +**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | +| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | +| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | + +**Batch 2 — quality of life (second PR: `feat/repo-ux`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | +| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | +| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | + +**Batch 3 — can wait** + +| Issue | Tool | Note | +|-------|------|------| +| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | +| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | + +### How to add a tool (pattern) + +Every tool = 4 files following `internal/tools/repo_get.go` exactly: + +1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) +2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() +3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer +4. Registration in main — find where `NewRepoGet` is registered, add new tool same place + +Key rules: +- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) +- Use `textOK(result)` for success output +- For `repo_mirror_push`: NEVER log or return `remote_password` in any output +- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name + +### Token permissions needed + +New tools require these additional Gitea token scopes: +- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create +- `delete_repo` — repo_delete + +Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` +If scopes are missing, update token in Gitea settings before running tests. + +### Definition of done + +- `task check` passes (all tools, all batches) +- Each new tool manually callable via `claude mcp call` +- PR #1 (batch 1) merged before starting batch 2 +- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed diff --git a/.context/system-prompt.txt b/.context/system-prompt.txt index 7475daf..61d4351 100644 --- a/.context/system-prompt.txt +++ b/.context/system-prompt.txt @@ -54,9 +54,10 @@ These rules apply to every task across every project, regardless of harness. | Build | Task (taskfile.dev) | Make | — | | Containers | Docker Compose (dev), k3s (prod) | — | — | | DB | PostgreSQL + sqlc | SQLite | — | -| Search | Qdrant (vector), BM25 | — | — | +| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | +| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -68,7 +69,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -76,7 +77,7 @@ Three machines on Tailscale: | Machine | Role | Key specs | |---------|------|-----------| -| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector | | iguana | Services, builds | M2 Ultra Mac | | flamingo | Daily driver, edge | Mac mini, ~/dev is here | @@ -257,4 +258,68 @@ When acting as a coding agent on this project: 5. When adding a dependency, explain why in the commit message 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +## Current sprint — gitea-mcp v0.2 (2026-05-14) + +### Context +This sprint implements new MCP tools needed for `hyperguild new-project` — +the automated project creation flow triggered from claude.ai. See brain knowledge +nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` +for full background. + +### Issues to implement (priority order) + +**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | +| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | +| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | + +**Batch 2 — quality of life (second PR: `feat/repo-ux`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | +| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | +| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | + +**Batch 3 — can wait** + +| Issue | Tool | Note | +|-------|------|------| +| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | +| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | + +### How to add a tool (pattern) + +Every tool = 4 files following `internal/tools/repo_get.go` exactly: + +1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) +2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() +3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer +4. Registration in main — find where `NewRepoGet` is registered, add new tool same place + +Key rules: +- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) +- Use `textOK(result)` for success output +- For `repo_mirror_push`: NEVER log or return `remote_password` in any output +- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name + +### Token permissions needed + +New tools require these additional Gitea token scopes: +- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create +- `delete_repo` — repo_delete + +Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` +If scopes are missing, update token in Gitea settings before running tests. + +### Definition of done + +- `task check` passes (all tools, all batches) +- Each new tool manually callable via `claude mcp call` +- PR #1 (batch 1) merged before starting batch 2 +- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed + --- diff --git a/.cursorrules b/.cursorrules index b2a35f0..1e224a4 100644 --- a/.cursorrules +++ b/.cursorrules @@ -52,9 +52,10 @@ These rules apply to every task across every project, regardless of harness. | Build | Task (taskfile.dev) | Make | — | | Containers | Docker Compose (dev), k3s (prod) | — | — | | DB | PostgreSQL + sqlc | SQLite | — | -| Search | Qdrant (vector), BM25 | — | — | +| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | +| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -66,7 +67,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -74,7 +75,7 @@ Three machines on Tailscale: | Machine | Role | Key specs | |---------|------|-----------| -| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector | | iguana | Services, builds | M2 Ultra Mac | | flamingo | Daily driver, edge | Mac mini, ~/dev is here | @@ -254,3 +255,67 @@ When acting as a coding agent on this project: 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM + +## Current sprint — gitea-mcp v0.2 (2026-05-14) + +### Context +This sprint implements new MCP tools needed for `hyperguild new-project` — +the automated project creation flow triggered from claude.ai. See brain knowledge +nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` +for full background. + +### Issues to implement (priority order) + +**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | +| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | +| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | + +**Batch 2 — quality of life (second PR: `feat/repo-ux`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | +| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | +| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | + +**Batch 3 — can wait** + +| Issue | Tool | Note | +|-------|------|------| +| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | +| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | + +### How to add a tool (pattern) + +Every tool = 4 files following `internal/tools/repo_get.go` exactly: + +1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) +2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() +3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer +4. Registration in main — find where `NewRepoGet` is registered, add new tool same place + +Key rules: +- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) +- Use `textOK(result)` for success output +- For `repo_mirror_push`: NEVER log or return `remote_password` in any output +- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name + +### Token permissions needed + +New tools require these additional Gitea token scopes: +- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create +- `delete_repo` — repo_delete + +Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` +If scopes are missing, update token in Gitea settings before running tests. + +### Definition of done + +- `task check` passes (all tools, all batches) +- Each new tool manually callable via `claude mcp call` +- PR #1 (batch 1) merged before starting batch 2 +- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed diff --git a/AGENTS.md b/AGENTS.md index d1730a9..230796d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,9 +49,10 @@ These rules apply to every task across every project, regardless of harness. | Build | Task (taskfile.dev) | Make | — | | Containers | Docker Compose (dev), k3s (prod) | — | — | | DB | PostgreSQL + sqlc | SQLite | — | -| Search | Qdrant (vector), BM25 | — | — | +| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | +| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -63,7 +64,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -71,7 +72,7 @@ Three machines on Tailscale: | Machine | Role | Key specs | |---------|------|-----------| -| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector | | iguana | Services, builds | M2 Ultra Mac | | flamingo | Daily driver, edge | Mac mini, ~/dev is here | @@ -251,3 +252,67 @@ When acting as a coding agent on this project: 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM + +## Current sprint — gitea-mcp v0.2 (2026-05-14) + +### Context +This sprint implements new MCP tools needed for `hyperguild new-project` — +the automated project creation flow triggered from claude.ai. See brain knowledge +nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` +for full background. + +### Issues to implement (priority order) + +**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | +| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | +| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | + +**Batch 2 — quality of life (second PR: `feat/repo-ux`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | +| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | +| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | + +**Batch 3 — can wait** + +| Issue | Tool | Note | +|-------|------|------| +| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | +| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | + +### How to add a tool (pattern) + +Every tool = 4 files following `internal/tools/repo_get.go` exactly: + +1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) +2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() +3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer +4. Registration in main — find where `NewRepoGet` is registered, add new tool same place + +Key rules: +- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) +- Use `textOK(result)` for success output +- For `repo_mirror_push`: NEVER log or return `remote_password` in any output +- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name + +### Token permissions needed + +New tools require these additional Gitea token scopes: +- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create +- `delete_repo` — repo_delete + +Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` +If scopes are missing, update token in Gitea settings before running tests. + +### Definition of done + +- `task check` passes (all tools, all batches) +- Each new tool manually callable via `claude mcp call` +- PR #1 (batch 1) merged before starting batch 2 +- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed diff --git a/CLAUDE.md b/CLAUDE.md index e89a596..934bde5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,3 +77,67 @@ When acting as a coding agent on this project: 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM + +## Current sprint — gitea-mcp v0.2 (2026-05-14) + +### Context +This sprint implements new MCP tools needed for `hyperguild new-project` — +the automated project creation flow triggered from claude.ai. See brain knowledge +nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` +for full background. + +### Issues to implement (priority order) + +**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | +| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | +| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | + +**Batch 2 — quality of life (second PR: `feat/repo-ux`)** + +| Issue | Tool | Gitea API | +|-------|------|-----------| +| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | +| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | +| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | + +**Batch 3 — can wait** + +| Issue | Tool | Note | +|-------|------|------| +| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | +| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | + +### How to add a tool (pattern) + +Every tool = 4 files following `internal/tools/repo_get.go` exactly: + +1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) +2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() +3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer +4. Registration in main — find where `NewRepoGet` is registered, add new tool same place + +Key rules: +- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) +- Use `textOK(result)` for success output +- For `repo_mirror_push`: NEVER log or return `remote_password` in any output +- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name + +### Token permissions needed + +New tools require these additional Gitea token scopes: +- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create +- `delete_repo` — repo_delete + +Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` +If scopes are missing, update token in Gitea settings before running tests. + +### Definition of done + +- `task check` passes (all tools, all batches) +- Each new tool manually callable via `claude mcp call` +- PR #1 (batch 1) merged before starting batch 2 +- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 647a0d9..95e0b35 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -60,6 +60,9 @@ func main() { reg.Register(tools.NewIssueComment(giteaClient, ownerAllow)) reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web")) reg.Register(tools.NewTagCreate(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow)) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/mirrors.go b/internal/gitea/mirrors.go new file mode 100644 index 0000000..4848e95 --- /dev/null +++ b/internal/gitea/mirrors.go @@ -0,0 +1,71 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" +) + +type PushMirror struct { + ID int `json:"id"` + RemoteName string `json:"remote_name"` + RemoteAddress string `json:"remote_address"` + Interval string `json:"interval"` + SyncOnCommit bool `json:"sync_on_commit"` +} + +type AddPushMirrorArgs struct { + RemoteAddress string `json:"remote_address"` + RemoteUsername string `json:"remote_username,omitempty"` + RemotePassword string `json:"remote_password,omitempty"` + Interval string `json:"interval,omitempty"` + SyncOnCommit bool `json:"sync_on_commit,omitempty"` +} + +func (c *Client) AddPushMirror(ctx context.Context, owner, repo string, args AddPushMirrorArgs) (*PushMirror, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo) + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + resp, status, err := c.PostJSON(ctx, path, body) + if err != nil { + return nil, err + } + if err := MapStatus(status, resp); err != nil { + return nil, err + } + var m PushMirror + if err := json.Unmarshal(resp, &m); err != nil { + return nil, err + } + return &m, nil +} + +func (c *Client) ListPushMirrors(ctx context.Context, owner, repo string) ([]PushMirror, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo) + resp, status, err := c.GetJSON(ctx, path) + if err != nil { + return nil, err + } + if err := MapStatus(status, resp); err != nil { + return nil, err + } + var mirrors []PushMirror + if err := json.Unmarshal(resp, &mirrors); err != nil { + return nil, err + } + return mirrors, nil +} + +func (c *Client) DeletePushMirror(ctx context.Context, owner, repo, mirrorName string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors/%s", owner, repo, mirrorName) + resp, status, err := c.DeleteJSON(ctx, path) + if err != nil { + return err + } + if status == 204 { + return nil + } + return MapStatus(status, resp) +} diff --git a/internal/gitea/mirrors_test.go b/internal/gitea/mirrors_test.go new file mode 100644 index 0000000..b173ac4 --- /dev/null +++ b/internal/gitea/mirrors_test.go @@ -0,0 +1,64 @@ +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 TestAddPushMirror(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{ + RemoteAddress: "https://github.com/mathias/infra.git", + RemoteUsername: "mathias", + RemotePassword: "secret", + Interval: "8h0m0s", + SyncOnCommit: true, + }) + require.NoError(t, err) + assert.Equal(t, "mirror-github", m.RemoteName) + assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress) +} + +func TestListPushMirrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra") + require.NoError(t, err) + require.Len(t, mirrors, 1) + assert.Equal(t, "mirror-github", mirrors[0].RemoteName) +} + +func TestDeletePushMirror(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github") + require.NoError(t, err) +} diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 0c5e3ad..77f6043 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -71,6 +71,70 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i return env.Data, nil } +type CreateRepoArgs struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Private bool `json:"private,omitempty"` + AutoInit bool `json:"auto_init,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + // Org, when non-empty, creates the repo under the named organisation. + // Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos. + Org string `json:"-"` +} + +func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) { + var path string + if args.Org != "" { + path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org) + } else { + path = "/api/v1/user/repos" + } + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + resp, status, err := c.PostJSON(ctx, path, body) + if err != nil { + return nil, err + } + if err := MapStatus(status, resp); err != nil { + return nil, err + } + var r Repo + if err := json.Unmarshal(resp, &r); err != nil { + return nil, err + } + return &r, nil +} + +// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero. +type UpdateRepoArgs struct { + Description *string `json:"description,omitempty"` + Private *bool `json:"private,omitempty"` + Website *string `json:"website,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` +} + +func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + resp, status, err := c.PatchJSON(ctx, path, body) + if err != nil { + return nil, err + } + if err := MapStatus(status, resp); err != nil { + return nil, err + } + var r Repo + if err := json.Unmarshal(resp, &r); err != nil { + return nil, err + } + return &r, nil +} + func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) { path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) body, status, err := c.GetJSON(ctx, path) diff --git a/internal/gitea/repos_test.go b/internal/gitea/repos_test.go index 741bd57..8a11f67 100644 --- a/internal/gitea/repos_test.go +++ b/internal/gitea/repos_test.go @@ -47,6 +47,63 @@ func TestListRepos(t *testing.T) { assert.Equal(t, "main", repos[0].DefaultBranch) } +func TestCreateRepo_User(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/user/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{ + Name: "infra", + Private: true, + }) + require.NoError(t, err) + assert.Equal(t, "mathias/infra", r.FullName) + assert.Equal(t, "main", r.DefaultBranch) +} + +func TestCreateRepo_Org(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{ + Name: "infra", + Org: "hyperguild", + }) + require.NoError(t, err) + assert.Equal(t, "hyperguild/infra", r.FullName) +} + +func TestUpdateRepo(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + })) + defer srv.Close() + + desc := "updated" + c := gitea.NewClient(srv.URL, "tok") + r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{ + Description: &desc, + }) + require.NoError(t, err) + assert.Equal(t, "updated", r.Description) +} + func TestDefaultBranchCachesAcrossCalls(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/tools/repo_create.go b/internal/tools/repo_create.go new file mode 100644 index 0000000..6563604 --- /dev/null +++ b/internal/tools/repo_create.go @@ -0,0 +1,74 @@ +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 RepoCreate struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoCreate(c *gitea.Client, a *allowlist.Allowlist) *RepoCreate { + return &RepoCreate{c: c, a: a} +} + +func (t *RepoCreate) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_create", + Description: "Create a repository for the authenticated user or an organisation.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string","description":"Username or org name (used for allowlist check)."}, + "name":{"type":"string","description":"Repository name."}, + "description":{"type":"string"}, + "private":{"type":"boolean","description":"Create as private. Default false."}, + "auto_init":{"type":"boolean","description":"Initialise with README."}, + "default_branch":{"type":"string","description":"Default branch name. Default 'main'."}, + "is_org":{"type":"boolean","description":"When true, create under the organisation named in 'owner'."} + }, + "required":["owner","name"] + }`), + } +} + +type repoCreateArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` + DefaultBranch string `json:"default_branch"` + IsOrg bool `json:"is_org"` +} + +func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoCreateArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + createArgs := gitea.CreateRepoArgs{ + Name: args.Name, + Description: args.Description, + Private: args.Private, + AutoInit: args.AutoInit, + DefaultBranch: args.DefaultBranch, + } + if args.IsOrg { + createArgs.Org = args.Owner + } + r, err := t.c.CreateRepo(ctx, createArgs) + if err != nil { + return nil, err + } + return textOK(r) +} diff --git a/internal/tools/repo_create_test.go b/internal/tools/repo_create_test.go new file mode 100644 index 0000000..fc64179 --- /dev/null +++ b/internal/tools/repo_create_test.go @@ -0,0 +1,53 @@ +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 TestRepoCreateTool_User(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/user/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + })) + defer srv.Close() + + tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":true}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"full_name":"mathias/infra"`) + assert.Contains(t, string(out), `"clone_url"`) +} + +func TestRepoCreateTool_Org(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`)) + })) + defer srv.Close() + + tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"hyperguild"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"hyperguild","name":"infra","is_org":true}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"full_name":"hyperguild/infra"`) +} + +func TestRepoCreateAllowlistRejects(t *testing.T) { + tool := tools.NewRepoCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`)) + require.Error(t, err) +} diff --git a/internal/tools/repo_mirror_push.go b/internal/tools/repo_mirror_push.go new file mode 100644 index 0000000..783c863 --- /dev/null +++ b/internal/tools/repo_mirror_push.go @@ -0,0 +1,117 @@ +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 RepoMirrorPush struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush { + return &RepoMirrorPush{c: c, a: a} +} + +func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_mirror_push", + Description: "Manage push mirrors for a repository: add, list, or delete.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "action":{"type":"string","enum":["add","list","delete"]}, + "remote_address":{"type":"string","description":"Mirror target URL (required for add)."}, + "remote_username":{"type":"string"}, + "remote_password":{"type":"string","description":"Never logged or returned."}, + "interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."}, + "sync_on_commit":{"type":"boolean"}, + "mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."} + }, + "required":["owner","name","action"] + }`), + } +} + +type repoMirrorPushArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Action string `json:"action"` + RemoteAddress string `json:"remote_address"` + RemoteUsername string `json:"remote_username"` + RemotePassword string `json:"remote_password"` + Interval string `json:"interval"` + SyncOnCommit bool `json:"sync_on_commit"` + MirrorName string `json:"mirror_name"` +} + +// safeMirror omits remote_password so it is never returned to the caller. +type safeMirror struct { + ID int `json:"id"` + RemoteName string `json:"remote_name"` + RemoteAddress string `json:"remote_address"` + Interval string `json:"interval"` + SyncOnCommit bool `json:"sync_on_commit"` +} + +func toSafeMirror(m *gitea.PushMirror) safeMirror { + return safeMirror{ + ID: m.ID, + RemoteName: m.RemoteName, + RemoteAddress: m.RemoteAddress, + Interval: m.Interval, + SyncOnCommit: m.SyncOnCommit, + } +} + +func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoMirrorPushArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + switch args.Action { + case "add": + m, err := t.c.AddPushMirror(ctx, args.Owner, args.Name, gitea.AddPushMirrorArgs{ + RemoteAddress: args.RemoteAddress, + RemoteUsername: args.RemoteUsername, + RemotePassword: args.RemotePassword, + Interval: args.Interval, + SyncOnCommit: args.SyncOnCommit, + }) + if err != nil { + return nil, err + } + return textOK(toSafeMirror(m)) + case "list": + mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Name) + if err != nil { + return nil, err + } + safe := make([]safeMirror, len(mirrors)) + for i := range mirrors { + safe[i] = toSafeMirror(&mirrors[i]) + } + return textOK(safe) + case "delete": + if args.MirrorName == "" { + return nil, fmt.Errorf("mirror_name is required for action=delete") + } + if err := t.c.DeletePushMirror(ctx, args.Owner, args.Name, args.MirrorName); err != nil { + return nil, err + } + return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName}) + default: + return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action) + } +} diff --git a/internal/tools/repo_mirror_push_test.go b/internal/tools/repo_mirror_push_test.go new file mode 100644 index 0000000..be90233 --- /dev/null +++ b/internal/tools/repo_mirror_push_test.go @@ -0,0 +1,80 @@ +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 TestRepoMirrorPushTool_Add(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`)) + })) + defer srv.Close() + + tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"mathias","name":"infra","action":"add", + "remote_address":"https://github.com/mathias/infra.git", + "remote_username":"mathias","remote_password":"secret", + "interval":"8h0m0s","sync_on_commit":true + }`)) + require.NoError(t, err) + // password must never appear in output + assert.NotContains(t, string(out), "secret") + assert.Contains(t, string(out), `"remote_name":"mirror-github"`) +} + +func TestRepoMirrorPushTool_List(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`)) + })) + defer srv.Close() + + tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"list"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"remote_name":"mirror-github"`) +} + +func TestRepoMirrorPushTool_Delete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete","mirror_name":"mirror-github"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), "deleted") +} + +func TestRepoMirrorPushTool_DeleteRequiresMirrorName(t *testing.T) { + tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "mirror_name") +} + +func TestRepoMirrorPushTool_AllowlistRejects(t *testing.T) { + tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","action":"list"}`)) + require.Error(t, err) +} diff --git a/internal/tools/repo_update.go b/internal/tools/repo_update.go new file mode 100644 index 0000000..74d22e3 --- /dev/null +++ b/internal/tools/repo_update.go @@ -0,0 +1,76 @@ +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 RepoUpdate struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate { + return &RepoUpdate{c: c, a: a} +} + +func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_update", + Description: "Update repository metadata (description, visibility, default branch, website).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "description":{"type":"string"}, + "private":{"type":"boolean"}, + "website":{"type":"string"}, + "default_branch":{"type":"string"}, + "confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."} + }, + "required":["owner","name"] + }`), + } +} + +type repoUpdateArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Description *string `json:"description"` + Private *bool `json:"private"` + Website *string `json:"website"` + DefaultBranch *string `json:"default_branch"` + Confirm string `json:"confirm"` +} + +func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoUpdateArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + // Making a repo public is a significant action — require explicit confirmation. + if args.Private != nil && !*args.Private { + if args.Confirm != args.Name { + return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name) + } + } + r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{ + Description: args.Description, + Private: args.Private, + Website: args.Website, + DefaultBranch: args.DefaultBranch, + }) + if err != nil { + return nil, err + } + return textOK(r) +} diff --git a/internal/tools/repo_update_test.go b/internal/tools/repo_update_test.go new file mode 100644 index 0000000..ff930cc --- /dev/null +++ b/internal/tools/repo_update_test.go @@ -0,0 +1,56 @@ +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 TestRepoUpdateTool(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + })) + defer srv.Close() + + tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"description":"updated"`) +} + +func TestRepoUpdateTool_MakePublicRequiresConfirm(t *testing.T) { + tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "confirm") +} + +func TestRepoUpdateTool_MakePublicWithConfirm(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":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`)) + })) + defer srv.Close() + + tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"full_name":"mathias/infra"`) +} + +func TestRepoUpdateAllowlistRejects(t *testing.T) { + tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`)) + require.Error(t, err) +}