Compare commits
4 Commits
d74b196db1
...
v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c94de7b59 | |||
|
|
e2da495581 | ||
|
|
7178ae32be | ||
| cb4f0caf0b |
@@ -36,6 +36,9 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| 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.
|
||||||
|
|
||||||
@@ -60,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
|
||||||
|
|
||||||
@@ -248,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| 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.
|
||||||
|
|
||||||
@@ -65,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
|
||||||
|
|
||||||
@@ -254,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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
70
.cursorrules
70
.cursorrules
@@ -39,6 +39,9 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| 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 +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
|
||||||
|
|
||||||
@@ -251,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
|
||||||
|
|||||||
70
AGENTS.md
70
AGENTS.md
@@ -36,6 +36,9 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| 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.
|
||||||
|
|
||||||
@@ -60,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
|
||||||
|
|
||||||
@@ -248,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
|
||||||
|
|||||||
64
CLAUDE.md
64
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
|
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
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ func main() {
|
|||||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
||||||
@@ -61,6 +60,9 @@ 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))
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
|
|||||||
71
internal/gitea/mirrors.go
Normal file
71
internal/gitea/mirrors.go
Normal 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)
|
||||||
|
}
|
||||||
64
internal/gitea/mirrors_test.go
Normal file
64
internal/gitea/mirrors_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -71,6 +71,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)
|
||||||
@@ -86,34 +150,3 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
|||||||
}
|
}
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditRepoArgs carries optional fields for PATCH /api/v1/repos/{owner}/{name}.
|
|
||||||
// Pointer fields let the caller omit unset values from the wire payload, so the
|
|
||||||
// server only patches what was explicitly requested.
|
|
||||||
type EditRepoArgs struct {
|
|
||||||
Archived *bool `json:"archived,omitempty"`
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
Private *bool `json:"private,omitempty"`
|
|
||||||
Website *string `json:"website,omitempty"`
|
|
||||||
Template *bool `json:"template,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) EditRepo(ctx context.Context, owner, name string, args EditRepoArgs) (*Repo, error) {
|
|
||||||
body, err := json.Marshal(args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal edit args: %w", err)
|
|
||||||
}
|
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,6 +47,63 @@ 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 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) {
|
||||||
|
|||||||
@@ -45,15 +45,14 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
|
|||||||
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "create_project_from_template",
|
Name: "create_project_from_template",
|
||||||
Description: "Create a new project repo from a template, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
|
Description: "Create a new project repo from the template, applying placeholder substitutions to known files.",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||||
"description":{"type":"string"},
|
"description":{"type":"string"},
|
||||||
"private":{"type":"boolean"},
|
"private":{"type":"boolean"}
|
||||||
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
|
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","name"]
|
||||||
}`),
|
}`),
|
||||||
@@ -65,7 +64,6 @@ type createProjectArgs struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
TemplateName string `json:"template_name"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type createProjectResult struct {
|
type createProjectResult struct {
|
||||||
@@ -93,20 +91,13 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve template: per-call override takes precedence over the
|
|
||||||
// server-configured default. Owner stays server-configured.
|
|
||||||
tmplName := args.TemplateName
|
|
||||||
if tmplName == "" {
|
|
||||||
tmplName = t.templateName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify template exists and is marked as a template repo.
|
// Verify template exists and is marked as a template repo.
|
||||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("template lookup: %w", err)
|
return nil, fmt.Errorf("template lookup: %w", err)
|
||||||
}
|
}
|
||||||
if !tmpl.Template {
|
if !tmpl.Template {
|
||||||
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify destination doesn't already exist.
|
// Verify destination doesn't already exist.
|
||||||
@@ -117,7 +108,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate repo from template.
|
// Generate repo from template.
|
||||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, tmplName, gitea.GenerateFromTemplateArgs{
|
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
|
||||||
Owner: args.Owner,
|
Owner: args.Owner,
|
||||||
Name: args.Name,
|
Name: args.Name,
|
||||||
Description: args.Description,
|
Description: args.Description,
|
||||||
|
|||||||
@@ -122,62 +122,6 @@ func TestCreateProjectHappyPath(t *testing.T) {
|
|||||||
assert.Empty(t, out.PartialFailure)
|
assert.Empty(t, out.PartialFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
|
|
||||||
// server-configured default, so the same binary can generate from template-go-web or
|
|
||||||
// template-go-agent without restart.
|
|
||||||
func TestCreateProjectTemplateNameOverride(t *testing.T) {
|
|
||||||
var templateLookups, generateCalls []string
|
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
switch {
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent":
|
|
||||||
templateLookups = append(templateLookups, "template-go-agent")
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
|
||||||
templateLookups = append(templateLookups, "template-go-web")
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
|
|
||||||
generateCalls = append(generateCalls, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
|
||||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
|
||||||
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
|
|
||||||
tool := newCreateProjectTool(srv.URL)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
||||||
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
|
|
||||||
))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
|
|
||||||
"override must direct the template lookup, not the server default")
|
|
||||||
require.Len(t, generateCalls, 1)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
|
|
||||||
"override must direct the /generate call too")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
||||||
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
||||||
tool := tools.NewCreateProjectFromTemplate(
|
tool := tools.NewCreateProjectFromTemplate(
|
||||||
|
|||||||
@@ -143,13 +143,7 @@ func splitUnifiedDiff(d []byte) map[string][]byte {
|
|||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
if currentFile != "" {
|
if currentFile != "" {
|
||||||
// Copy: bytes.Buffer.Bytes() returns the internal slice,
|
m[currentFile] = current.Bytes()
|
||||||
// which Reset() then reuses. Without the copy, every map
|
|
||||||
// entry ends up aliased to the last file's data.
|
|
||||||
b := current.Bytes()
|
|
||||||
cp := make([]byte, len(b))
|
|
||||||
copy(cp, b)
|
|
||||||
m[currentFile] = cp
|
|
||||||
current.Reset()
|
current.Reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,47 +97,6 @@ func TestPRFilesDiffSmall(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, fileNames, paths)
|
assert.ElementsMatch(t, fileNames, paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression for issue #25: every file's diff entry must contain its OWN diff,
|
|
||||||
// not a shared buffer pointing at the last file. Prior bug: splitUnifiedDiff
|
|
||||||
// flushed bytes.Buffer.Bytes() into the map without copying, so every entry
|
|
||||||
// aliased the buffer's backing array and showed the last file's content.
|
|
||||||
func TestPRFilesDiffPerFileIsolation(t *testing.T) {
|
|
||||||
fileNames := []string{"alpha.go", "beta.go", "gamma.go", "delta.go"}
|
|
||||||
rawDiff := buildDiff(fileNames, 5)
|
|
||||||
filesJSON := buildFilesJSON(fileNames, 5)
|
|
||||||
|
|
||||||
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
|
||||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var out struct {
|
|
||||||
Files []struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Diff string `json:"diff"`
|
|
||||||
} `json:"files"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.Unmarshal(result, &out))
|
|
||||||
require.Len(t, out.Files, len(fileNames))
|
|
||||||
|
|
||||||
for _, f := range out.Files {
|
|
||||||
expected := fmt.Sprintf("diff --git a/%s b/%s", f.Path, f.Path)
|
|
||||||
assert.Contains(t, f.Diff, expected,
|
|
||||||
"file %s diff must contain its own header, got: %.80q", f.Path, f.Diff)
|
|
||||||
// No other file's header should leak in.
|
|
||||||
for _, other := range fileNames {
|
|
||||||
if other == f.Path {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
otherHeader := fmt.Sprintf("diff --git a/%s b/%s", other, other)
|
|
||||||
assert.NotContains(t, f.Diff, otherHeader,
|
|
||||||
"file %s diff must NOT contain %s's header", f.Path, other)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
||||||
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
||||||
fileNames := []string{"bigfile.go"}
|
fileNames := []string{"bigfile.go"}
|
||||||
|
|||||||
74
internal/tools/repo_create.go
Normal file
74
internal/tools/repo_create.go
Normal 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)
|
||||||
|
}
|
||||||
53
internal/tools/repo_create_test.go
Normal file
53
internal/tools/repo_create_test.go
Normal 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)
|
||||||
|
}
|
||||||
117
internal/tools/repo_mirror_push.go
Normal file
117
internal/tools/repo_mirror_push.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
internal/tools/repo_mirror_push_test.go
Normal file
80
internal/tools/repo_mirror_push_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -22,19 +22,17 @@ func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
|
|||||||
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "repo_update",
|
Name: "repo_update",
|
||||||
Description: "Update repository metadata via PATCH (archived, description, private, website, template). " +
|
Description: "Update repository metadata (description, visibility, default branch, website).",
|
||||||
"Only fields explicitly set in the call are patched. " +
|
|
||||||
"WARNING: private=false exposes the repo publicly — verify intent before calling.",
|
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"name":{"type":"string"},
|
||||||
"archived":{"type":"boolean","description":"Mark repo as archived (read-only). Reversible."},
|
|
||||||
"description":{"type":"string"},
|
"description":{"type":"string"},
|
||||||
"private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."},
|
"private":{"type":"boolean"},
|
||||||
"website":{"type":"string","description":"Homepage URL"},
|
"website":{"type":"string"},
|
||||||
"template":{"type":"boolean","description":"Toggle template-repo flag"}
|
"default_branch":{"type":"string"},
|
||||||
|
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","name"]
|
||||||
}`),
|
}`),
|
||||||
@@ -44,11 +42,11 @@ func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
|||||||
type repoUpdateArgs struct {
|
type repoUpdateArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Archived *bool `json:"archived,omitempty"`
|
Description *string `json:"description"`
|
||||||
Description *string `json:"description,omitempty"`
|
Private *bool `json:"private"`
|
||||||
Private *bool `json:"private,omitempty"`
|
Website *string `json:"website"`
|
||||||
Website *string `json:"website,omitempty"`
|
DefaultBranch *string `json:"default_branch"`
|
||||||
Template *bool `json:"template,omitempty"`
|
Confirm string `json:"confirm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
@@ -59,23 +57,20 @@ func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Name == "" {
|
// Making a repo public is a significant action — require explicit confirmation.
|
||||||
return nil, fmt.Errorf("name required: %w", gitea.ErrValidation)
|
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)
|
||||||
}
|
}
|
||||||
if args.Archived == nil && args.Description == nil && args.Private == nil &&
|
|
||||||
args.Website == nil && args.Template == nil {
|
|
||||||
return nil, fmt.Errorf("at least one updatable field must be set: %w", gitea.ErrValidation)
|
|
||||||
}
|
}
|
||||||
|
r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
|
||||||
updated, err := t.c.EditRepo(ctx, args.Owner, args.Name, gitea.EditRepoArgs{
|
|
||||||
Archived: args.Archived,
|
|
||||||
Description: args.Description,
|
Description: args.Description,
|
||||||
Private: args.Private,
|
Private: args.Private,
|
||||||
Website: args.Website,
|
Website: args.Website,
|
||||||
Template: args.Template,
|
DefaultBranch: args.DefaultBranch,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("edit repo: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return textOK(updated)
|
return textOK(r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package tools_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -15,125 +14,43 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRepoUpdateTool(srvURL string) *tools.RepoUpdate {
|
func TestRepoUpdateTool(t *testing.T) {
|
||||||
return tools.NewRepoUpdate(gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRepoUpdateArchive: happy path — set archived=true.
|
|
||||||
func TestRepoUpdateArchive(t *testing.T) {
|
|
||||||
var patchedBody []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) {
|
||||||
require.Equal(t, http.MethodPatch, r.Method)
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
require.Equal(t, "/api/v1/repos/mathias/old-svc", r.URL.Path)
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
patchedBody, _ = io.ReadAll(r.Body)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`))
|
_, _ = 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()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := newRepoUpdateTool(srv.URL)
|
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
result, err := tool.Call(context.Background(), json.RawMessage(
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`))
|
||||||
`{"owner":"mathias","name":"old-svc","archived":true}`,
|
|
||||||
))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"description":"updated"`)
|
||||||
// Wire payload only contains the field that was actually set.
|
|
||||||
var sent map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
|
||||||
assert.Equal(t, true, sent["archived"])
|
|
||||||
assert.NotContains(t, sent, "description")
|
|
||||||
assert.NotContains(t, sent, "private")
|
|
||||||
assert.NotContains(t, sent, "website")
|
|
||||||
assert.NotContains(t, sent, "template")
|
|
||||||
|
|
||||||
var repo gitea.Repo
|
|
||||||
require.NoError(t, json.Unmarshal(result, &repo))
|
|
||||||
assert.Equal(t, "mathias/old-svc", repo.FullName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRepoUpdateMultipleFields: set description + template flag in one call.
|
func TestRepoUpdateTool_MakePublicRequiresConfirm(t *testing.T) {
|
||||||
func TestRepoUpdateMultipleFields(t *testing.T) {
|
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
var patchedBody []byte
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`))
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
patchedBody, _ = io.ReadAll(r.Body)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"name":"template-go-agent","full_name":"mathias/template-go-agent","description":"Go agent template","template":true}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := newRepoUpdateTool(srv.URL)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
||||||
`{"owner":"mathias","name":"template-go-agent","description":"Go agent template","template":true}`,
|
|
||||||
))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var sent map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
|
||||||
assert.Equal(t, "Go agent template", sent["description"])
|
|
||||||
assert.Equal(t, true, sent["template"])
|
|
||||||
assert.NotContains(t, sent, "archived")
|
|
||||||
assert.NotContains(t, sent, "private")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRepoUpdateNoFieldsRejected: zero updatable fields → validation error before network.
|
|
||||||
func TestRepoUpdateNoFieldsRejected(t *testing.T) {
|
|
||||||
tool := tools.NewRepoUpdate(
|
|
||||||
gitea.NewClient("http://unused", ""),
|
|
||||||
allowlist.New([]string{"mathias"}),
|
|
||||||
)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
||||||
`{"owner":"mathias","name":"some-repo"}`,
|
|
||||||
))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
assert.Contains(t, err.Error(), "confirm")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRepoUpdateMakePublic: explicit private=false is allowed; wire payload carries the false.
|
func TestRepoUpdateTool_MakePublicWithConfirm(t *testing.T) {
|
||||||
// (The destructive nature is warned about in the tool description, not blocked by the tool.)
|
|
||||||
func TestRepoUpdateMakePublic(t *testing.T) {
|
|
||||||
var patchedBody []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) {
|
||||||
patchedBody, _ = io.ReadAll(r.Body)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"name":"open-repo","full_name":"mathias/open-repo","private":false}`))
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := newRepoUpdateTool(srv.URL)
|
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`))
|
||||||
`{"owner":"mathias","name":"open-repo","private":false}`,
|
|
||||||
))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
|
||||||
var sent map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
|
||||||
assert.Equal(t, false, sent["private"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
|
|
||||||
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
||||||
tool := tools.NewRepoUpdate(
|
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
gitea.NewClient("http://unused", ""),
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||||
allowlist.New([]string{"mathias"}),
|
|
||||||
)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
||||||
`{"owner":"evil","name":"some-repo","archived":true}`,
|
|
||||||
))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRepoUpdateUpstreamError: server 500 propagates as ErrUpstream.
|
|
||||||
func TestRepoUpdateUpstreamError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"internal"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := newRepoUpdateTool(srv.URL)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
||||||
`{"owner":"mathias","name":"some-repo","archived":true}`,
|
|
||||||
))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.ErrorIs(t, err, gitea.ErrUpstream)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user