8 Commits

Author SHA1 Message Date
3b490271ef Merge pull request 'feat(tools): issue_get, release_create, repo_delete (#11, #17, #20)' (#23) from feat/batch-3 into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
2026-05-15 12:00:09 +00:00
Mathias Bergqvist
d4dddbdb6c feat(tools): issue_get, release_create, repo_delete (#11, #17, #20)
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
issue_get: GET /repos/{owner}/{repo}/issues/{number} — full issue with labels, assignees, comment count
release_create: POST /repos/{owner}/{repo}/releases — create release and tag in one call
repo_delete: DELETE /repos/{owner}/{repo} — confirm=<repo name> required, blocks accidents
2026-05-15 13:59:06 +02:00
a69d3a8b76 Merge pull request 'feat(tools): repo_tree, repo_topics_update, file_read dir fix (#14, #15, #18)' (#22) from feat/repo-ux into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
2026-05-15 08:24:35 +00:00
Mathias Bergqvist
5f3ad99122 feat(tools): repo_tree, repo_topics_update, file_read dir fix (#14, #15, #18)
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
repo_tree: GET /git/trees/{ref}?recursive=1 — full recursive file tree
repo_topics_update: PUT /repos/{owner}/{repo}/topics — replace topic list
file_read: detect array response and return descriptive error for dir paths
2026-05-15 10:23:31 +02:00
2c94de7b59 Merge pull request 'feat(tools): repo_create, repo_update, repo_mirror_push (#12, #13, #16)' (#21) from feat/repo-crud into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
2026-05-15 08:21:23 +00:00
Mathias Bergqvist
e2da495581 feat(tools): add repo_create, repo_update, repo_mirror_push (#12, #13, #16)
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
repo_create: POST /user/repos or /orgs/{org}/repos, is_org flag routes
repo_update: PATCH /repos/{owner}/{repo}, confirm required when private=false
repo_mirror_push: add/list/delete push mirrors, password never returned
2026-05-15 10:14:18 +02:00
Mathias Bergqvist
7178ae32be chore: re-sync context adapters 2026-05-15 09:53:09 +02:00
cb4f0caf0b docs: add current sprint context for gitea-mcp v0.2 tools (#11-#19)
All checks were successful
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 3s
2026-05-14 21:27:29 +00:00
31 changed files with 1896 additions and 17 deletions

View File

@@ -49,9 +49,10 @@ These rules apply to every task across every project, regardless of harness.
| Build | Task (taskfile.dev) | Make | — | | Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — | | Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — | | DB | PostgreSQL + sqlc | SQLite | — |
| Search | Qdrant (vector), BM25 | | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **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* - **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 - **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 ## Infrastructure
@@ -71,7 +72,7 @@ Three machines on Tailscale:
| Machine | Role | Key specs | | 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 | | iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here | | 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 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 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 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/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_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

View File

@@ -77,3 +77,67 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 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 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/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_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

View File

@@ -54,9 +54,10 @@ These rules apply to every task across every project, regardless of harness.
| Build | Task (taskfile.dev) | Make | — | | Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — | | Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — | | DB | PostgreSQL + sqlc | SQLite | — |
| Search | Qdrant (vector), BM25 | | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **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* - **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 - **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 ## Infrastructure
@@ -76,7 +77,7 @@ Three machines on Tailscale:
| Machine | Role | Key specs | | 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 | | iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here | | 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 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 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/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_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
--- ---

View File

@@ -52,9 +52,10 @@ These rules apply to every task across every project, regardless of harness.
| Build | Task (taskfile.dev) | Make | — | | Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — | | Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — | | DB | PostgreSQL + sqlc | SQLite | — |
| Search | Qdrant (vector), BM25 | | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **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* - **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 - **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 ## Infrastructure
@@ -74,7 +75,7 @@ Three machines on Tailscale:
| Machine | Role | Key specs | | 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 | | iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here | | 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 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 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 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/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_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

View File

@@ -49,9 +49,10 @@ These rules apply to every task across every project, regardless of harness.
| Build | Task (taskfile.dev) | Make | — | | Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — | | Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — | | DB | PostgreSQL + sqlc | SQLite | — |
| Search | Qdrant (vector), BM25 | | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **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* - **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 - **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 ## Infrastructure
@@ -71,7 +72,7 @@ Three machines on Tailscale:
| Machine | Role | Key specs | | 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 | | iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here | | 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 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 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 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/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_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

View File

@@ -77,3 +77,67 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 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 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/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_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

View File

@@ -60,6 +60,14 @@ func main() {
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow)) reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web")) reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow)) reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
mcpSrv := mcp.NewServer(mcp.ServerOptions{ mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg, Registry: reg,

View File

@@ -27,6 +27,10 @@ func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref str
if err := MapStatus(status, body); err != nil { if err := MapStatus(status, body); err != nil {
return nil, err return nil, err
} }
// Array response means path is a directory — guide caller to dir_list.
if len(body) > 0 && body[0] == '[' {
return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path)
}
var fc FileContents var fc FileContents
if err := json.Unmarshal(body, &fc); err != nil { if err := json.Unmarshal(body, &fc); err != nil {
return nil, err return nil, err

View File

@@ -7,11 +7,25 @@ import (
) )
type Issue struct { type Issue struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
State string `json:"state"` State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Labels []Label `json:"labels"`
Assignees []User `json:"assignees"`
Comments int `json:"comments"`
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type User struct {
Login string `json:"login"`
} }
type CreateIssueArgs struct { type CreateIssueArgs struct {
@@ -22,6 +36,22 @@ type CreateIssueArgs struct {
Milestone int64 `json:"milestone,omitempty"` Milestone int64 `json:"milestone,omitempty"`
} }
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var iss Issue
if err := json.Unmarshal(body, &iss); err != nil {
return nil, err
}
return &iss, nil
}
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) { func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo) p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload, err := json.Marshal(args) payload, err := json.Marshal(args)

View File

@@ -45,6 +45,37 @@ func TestCreateIssue(t *testing.T) {
assert.Equal(t, "open", iss.State) assert.Equal(t, "open", iss.State)
} }
func TestGetIssue(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/o/r/issues/42", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Equal(t, 42, iss.Number)
assert.Equal(t, "fix auth", iss.Title)
assert.Equal(t, "open", iss.State)
assert.Equal(t, 3, iss.Comments)
}
func TestGetIssue_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.GetIssue(context.Background(), "o", "r", 999)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestCreateIssueComment(t *testing.T) { func TestCreateIssueComment(t *testing.T) {
var captured []byte var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

71
internal/gitea/mirrors.go Normal file
View File

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

View File

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

View File

@@ -18,6 +18,109 @@ type Repo struct {
Template bool `json:"template"` Template bool `json:"template"`
} }
type TreeEntry struct {
Path string `json:"path"`
Type string `json:"type"` // "blob" or "tree"
SHA string `json:"sha"`
Size int64 `json:"size"`
URL string `json:"url"`
}
type Tree struct {
SHA string `json:"sha"`
URL string `json:"url"`
Tree []TreeEntry `json:"tree"`
Truncated bool `json:"truncated"`
}
func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref))
if recursive {
path += "?recursive=1"
}
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var t Tree
if err := json.Unmarshal(body, &t); err != nil {
return nil, err
}
return &t, nil
}
type Release struct {
ID int64 `json:"id"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
HTMLURL string `json:"html_url"`
CreatedAt string `json:"created_at"`
}
type CreateReleaseArgs struct {
TagName string `json:"tag_name"`
Name string `json:"name,omitempty"`
Body string `json:"body,omitempty"`
Draft bool `json:"draft,omitempty"`
Prerelease bool `json:"prerelease,omitempty"`
// Target branch or commit SHA for tag creation. Empty = repo default branch.
Target string `json:"target_commitish,omitempty"`
}
func (c *Client) CreateRelease(ctx context.Context, owner, repo string, args CreateReleaseArgs) (*Release, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", 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 r Release
if err := json.Unmarshal(resp, &r); err != nil {
return nil, err
}
return &r, nil
}
func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo)
resp, status, err := c.DeleteJSON(ctx, path)
if err != nil {
return err
}
if status == 204 {
return nil
}
return MapStatus(status, resp)
}
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
body, err := json.Marshal(map[string][]string{"topics": topics})
if err != nil {
return err
}
resp, status, err := c.PutJSON(ctx, path, body)
if err != nil {
return err
}
if status == 204 {
return nil
}
return MapStatus(status, resp)
}
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) { func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -71,6 +174,70 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i
return env.Data, nil 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) { func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
body, status, err := c.GetJSON(ctx, path) body, status, err := c.GetJSON(ctx, path)

View File

@@ -47,6 +47,129 @@ func TestListRepos(t *testing.T) {
assert.Equal(t, "main", repos[0].DefaultBranch) 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 TestGetTree(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true)
require.NoError(t, err)
assert.Equal(t, "abc", tree.SHA)
require.Len(t, tree.Tree, 2)
assert.Equal(t, "README.md", tree.Tree[0].Path)
assert.Equal(t, "blob", tree.Tree[0].Type)
assert.Equal(t, int64(13), tree.Tree[0].Size)
}
func TestUpdateTopics(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"})
require.NoError(t, err)
}
func TestCreateRelease(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/releases", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
TagName: "v1.0.0",
Name: "v1.0.0",
Body: "first release",
})
require.NoError(t, err)
assert.Equal(t, "v1.0.0", rel.TagName)
assert.Equal(t, "first release", rel.Body)
}
func TestDeleteRepo(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", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.DeleteRepo(context.Background(), "mathias", "infra")
require.NoError(t, err)
}
func TestDefaultBranchCachesAcrossCalls(t *testing.T) { func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
var hits int32 var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

View File

@@ -57,6 +57,21 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
assert.Equal(t, "main", result["ref"]) assert.Equal(t, "main", result["ref"])
} }
func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Gitea returns an array when path is a directory
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`))
}))
defer srv.Close()
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "directory")
assert.Contains(t, err.Error(), "dir_list")
}
func TestFileReadAllowlistRejects(t *testing.T) { func TestFileReadAllowlistRejects(t *testing.T) {
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`)) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))

View File

@@ -0,0 +1,54 @@
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 IssueGet struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_get",
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type issueGetArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
}
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueGetArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
iss, err := t.c.GetIssue(ctx, args.Owner, args.Name, args.Number)
if err != nil {
return nil, err
}
return textOK(iss)
}

View File

@@ -0,0 +1,50 @@
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 TestIssueGetTool(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/issues/42", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
}))
defer srv.Close()
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":42`)
assert.Contains(t, string(out), `"title":"fix auth"`)
assert.Contains(t, string(out), `"comments":3`)
}
func TestIssueGetTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
require.Error(t, err)
}
func TestIssueGetAllowlistRejects(t *testing.T) {
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,73 @@
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 ReleaseCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewReleaseCreate(c *gitea.Client, a *allowlist.Allowlist) *ReleaseCreate {
return &ReleaseCreate{c: c, a: a}
}
func (t *ReleaseCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "release_create",
Description: "Create a release (and tag if it doesn't exist) for a repository.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"tag_name":{"type":"string","description":"Tag to create or use, e.g. 'v1.0.0'."},
"release_name":{"type":"string","description":"Display name for the release."},
"body":{"type":"string","description":"Release notes / changelog."},
"draft":{"type":"boolean"},
"prerelease":{"type":"boolean"},
"target":{"type":"string","description":"Branch or commit SHA to tag. Defaults to repo default branch."}
},
"required":["owner","name","tag_name"]
}`),
}
}
type releaseCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
TagName string `json:"tag_name"`
ReleaseName string `json:"release_name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Target string `json:"target"`
}
func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args releaseCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Name, gitea.CreateReleaseArgs{
TagName: args.TagName,
Name: args.ReleaseName,
Body: args.Body,
Draft: args.Draft,
Prerelease: args.Prerelease,
Target: args.Target,
})
if err != nil {
return nil, err
}
return textOK(rel)
}

View File

@@ -0,0 +1,38 @@
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 TestReleaseCreateTool(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/releases", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"changelog","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
}))
defer srv.Close()
tool := tools.NewReleaseCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","tag_name":"v1.0.0","release_name":"v1.0.0","body":"changelog"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"tag_name":"v1.0.0"`)
assert.Contains(t, string(out), `"html_url"`)
}
func TestReleaseCreateAllowlistRejects(t *testing.T) {
tool := tools.NewReleaseCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","tag_name":"v1.0.0"}`))
require.Error(t, err)
}

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
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 RepoDelete struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoDelete(c *gitea.Client, a *allowlist.Allowlist) *RepoDelete {
return &RepoDelete{c: c, a: a}
}
func (t *RepoDelete) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_delete",
Description: "Permanently delete a repository. Requires confirm=<repo name> to prevent accidents.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"confirm":{"type":"string","description":"Must equal the repo name exactly to proceed."}
},
"required":["owner","name","confirm"]
}`),
}
}
type repoDeleteArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Confirm string `json:"confirm"`
}
func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoDeleteArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Confirm != args.Name {
return nil, fmt.Errorf("repo_delete requires confirm=%q to match the repo name — got %q", args.Name, args.Confirm)
}
if err := t.c.DeleteRepo(ctx, args.Owner, args.Name); err != nil {
return nil, err
}
return textOK(map[string]string{"status": "deleted", "repo": args.Owner + "/" + args.Name})
}

View File

@@ -0,0 +1,52 @@
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 TestRepoDeleteTool_WithCorrectConfirm(t *testing.T) {
deleted := false
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", r.URL.Path)
deleted = true
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewRepoDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"infra"}`))
require.NoError(t, err)
assert.True(t, deleted, "DELETE request must have been sent")
assert.Contains(t, string(out), "deleted")
}
func TestRepoDeleteTool_WrongConfirmBlocked(t *testing.T) {
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"wrong"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "confirm")
}
func TestRepoDeleteTool_MissingConfirmBlocked(t *testing.T) {
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "confirm")
}
func TestRepoDeleteAllowlistRejects(t *testing.T) {
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","confirm":"x"}`))
require.Error(t, err)
}

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
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 RepoTopicsUpdate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoTopicsUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoTopicsUpdate {
return &RepoTopicsUpdate{c: c, a: a}
}
func (t *RepoTopicsUpdate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_topics_update",
Description: "Replace the topic list for a repository.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"topics":{"type":"array","items":{"type":"string"},"description":"Full replacement list. Send [] to clear all topics."}
},
"required":["owner","name","topics"]
}`),
}
}
type repoTopicsUpdateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Topics []string `json:"topics"`
}
func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoTopicsUpdateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if err := t.c.UpdateTopics(ctx, args.Owner, args.Name, args.Topics); err != nil {
return nil, err
}
return textOK(map[string]any{"status": "updated", "topics": args.Topics})
}

View File

@@ -0,0 +1,35 @@
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 TestRepoTopicsUpdateTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewRepoTopicsUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","topics":["go","mcp","gitops"]}`))
require.NoError(t, err)
assert.Contains(t, string(out), "updated")
}
func TestRepoTopicsUpdateAllowlistRejects(t *testing.T) {
tool := tools.NewRepoTopicsUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","topics":[]}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,56 @@
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 RepoTree struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoTree(c *gitea.Client, a *allowlist.Allowlist) *RepoTree {
return &RepoTree{c: c, a: a}
}
func (t *RepoTree) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_tree",
Description: "Get the full recursive file tree for a repo ref (branch, tag, or SHA).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"ref":{"type":"string","description":"Branch, tag, or commit SHA."}
},
"required":["owner","name","ref"]
}`),
}
}
type repoTreeArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Ref string `json:"ref"`
}
func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoTreeArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, args.Ref, true)
if err != nil {
return nil, err
}
return textOK(tree)
}

View File

@@ -0,0 +1,50 @@
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 TestRepoTreeTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi","size":0}],"truncated":false}`))
}))
defer srv.Close()
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"sha":"abc"`)
assert.Contains(t, string(out), `"path":"README.md"`)
}
func TestRepoTreeTool_DefaultsToRecursive(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sha":"abc","tree":[],"truncated":false}`))
}))
defer srv.Close()
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
require.NoError(t, err)
}
func TestRepoTreeAllowlistRejects(t *testing.T) {
tool := tools.NewRepoTree(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","ref":"main"}`))
require.Error(t, err)
}

View File

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

View File

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