10 Commits

Author SHA1 Message Date
e31fd3f023 Merge pull request 'fix/v02-patch: pr_files_diff, template_name, repo_update' (#26) from fix/v02-patch into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
Reviewed-on: #26
2026-05-16 22:03:29 +00:00
Mathias
3cccbfb8cb chore: re-sync context adapters after rebase
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
Upstream .context/PROJECT.md gained a branch-protection rule + an
extra agent instruction. Pure regeneration via scripts/context-sync.sh
to make task check pass before force-push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:02:08 +02:00
3648373333 fix: merge repo_update — add archived+template, keep default_branch+confirm from main 2026-05-16 23:54:16 +02:00
Mathias
eeefc626ed feat(repo_update): tool for archiving + metadata patches
Adds a repo_update tool exposing PATCH /api/v1/repos/{owner}/{name}
with optional pointer fields (archived, description, private,
website, template). Only fields set by the caller are sent on the
wire, so the server patches exactly what was asked for.

Originally needed to archive ingestion-svc cleanly instead of
leaving a README tombstone, and to flip template-go-{agent,web}
to template=true so create_project_from_template stops failing
the "is not marked as template" guard.

Wire-level enforcement of "at least one field" returns ErrValidation
before any network call, preventing no-op PATCHes.

private=false (making a repo public) is allowed but flagged in the
tool description with a "verify intent before calling" warning.
The earlier issue draft suggested an ntfy confirmation hook for
that path — out of scope for this PR; the warning string is the
minimum that fits inside the tool surface today.

Wires NewRepoUpdate into cmd/gitea-mcp/main.go alongside the rest
of the repo_* family.

Closes #12
2026-05-16 23:54:16 +02:00
Mathias
5545d6ab4b fix(create_project_from_template): accept per-call template_name override
The template name was hardcoded into the binary at startup via
NewCreateProjectFromTemplate("mathias", "template-go-web"), so
generating from a different template (e.g. template-go-agent)
required a code change and restart. The constructor already
parameterised it correctly — the gap was at the tool's input
schema, which never exposed template_name to the caller.

Adds an optional template_name input field. When set, it overrides
the server-configured default for that call only; when omitted,
behavior is unchanged. Template owner stays server-configured —
only the repo name is per-call.

Server-side validation already verifies the resolved template
exists and is marked as a template repo, so no enum constraint
is added — keeps the door open for future templates (go-ml,
go-service, ...) without redeploys.

Adds TestCreateProjectTemplateNameOverride verifying the override
directs both the template lookup and the /generate POST.

Closes #24
2026-05-16 23:24:16 +02:00
Mathias
9013c8ff9c fix(pr_files_diff): copy per-file diff bytes to break buffer aliasing
splitUnifiedDiff used bytes.Buffer to accumulate each file's diff,
then stored buf.Bytes() into the result map and called buf.Reset()
to start the next file. bytes.Buffer.Bytes() returns the buffer's
internal backing slice; Reset() resets length to 0 but reuses the
same backing array. As a result, every map entry aliased the same
storage, so all files ended up showing the LAST file's diff content.

Fix: copy the bytes into a fresh slice before storing in the map.

Adds TestPRFilesDiffPerFileIsolation as a regression test that
asserts each file entry contains its OWN diff --git header and
none of the other files' headers. Verified failing on the prior
code, passing after the fix.

Closes #25
2026-05-16 23:24:16 +02:00
Mathias
f26f922c96 chore: re-sync context adapters with upstream root
Derived adapters drifted from canonical root .context/AGENT.md after
the pgvector default change landed upstream. Pure regeneration via
scripts/context-sync.sh, no manual edits. Required to make task check
pass before the feature commits on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:24:16 +02:00
a414222610 docs: update sprint to v0.2 patch — fixes #12, #24, #25
All checks were successful
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Successful in 3s
2026-05-16 20:43:29 +00:00
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
23 changed files with 1211 additions and 337 deletions

View File

@@ -36,9 +36,6 @@ 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,7 +49,6 @@ 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.
@@ -64,7 +60,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, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure ## Infrastructure
@@ -214,6 +210,7 @@ Key skills:
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description` - Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what* - PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -251,68 +248,98 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask 3. If unsure about a convention, check `DECISIONS.md` or ask
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. Always work on a feature branch and open a PR — never push directly to main
7. 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) ## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
### Context ### Context
This sprint implements new MCP tools needed for `hyperguild new-project` The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
the automated project creation flow triggered from claude.ai. See brain knowledge repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` create_project_from_template) was implemented and pushed directly to main.
for full background.
### Issues to implement (priority order) This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** ### Issues to fix (all three in one PR: `fix/v02-patch`)
| Issue | Tool | Gitea API | #### #12 — repo_update: add `archived` and `template` fields
|-------|------|-----------| **File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | **File:** `internal/tools/repo_update.go` → input schema + args struct
| #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`)** Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
| Issue | Tool | Gitea API | Add to tool input schema:
|-------|------|-----------| ```json
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | "archived": {
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | "type": "boolean",
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | "description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
**Batch 3 — can wait** Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
| Issue | Tool | Note | New test cases to add in `repo_update_test.go`:
|-------|------|------| - `TestRepoUpdateTool_Archive` — happy path with confirm
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | - `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | - `TestRepoUpdateTool_SetTemplate` — no confirm needed
### How to add a tool (pattern) #### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Every tool = 4 files following `internal/tools/repo_get.go` exactly: Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call() Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer the tool resolves it internally.
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules: New test case: `TestCreateProjectFromTemplate_AgentTemplate`
- 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 #### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
New tools require these additional Gitea token scopes: There is a loop bug where all file entries in the response contain the same diff
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create (the first file's diff is reused for every subsequent file). Find the loop and
- `delete_repo` — repo_delete ensure each iteration reads and assigns the correct diff for its own file.
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
If scopes are missing, update token in Gitea settings before running tests. a distinct diff.
### Definition of done ### Definition of done
- `task check` passes (all tools, all batches) - [ ] `task check` passes
- Each new tool manually callable via `claude mcp call` - [ ] `repo_update` accepts `archived` and `template` params
- PR #1 (batch 1) merged before starting batch 2 - [ ] `archived=true` requires `confirm=<repo name>`
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed - [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.

View File

@@ -39,6 +39,7 @@
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description` - Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what* - PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -76,68 +77,98 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask 3. If unsure about a convention, check `DECISIONS.md` or ask
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. Always work on a feature branch and open a PR — never push directly to main
7. 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) ## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
### Context ### Context
This sprint implements new MCP tools needed for `hyperguild new-project` The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
the automated project creation flow triggered from claude.ai. See brain knowledge repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` create_project_from_template) was implemented and pushed directly to main.
for full background.
### Issues to implement (priority order) This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** ### Issues to fix (all three in one PR: `fix/v02-patch`)
| Issue | Tool | Gitea API | #### #12 — repo_update: add `archived` and `template` fields
|-------|------|-----------| **File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | **File:** `internal/tools/repo_update.go` → input schema + args struct
| #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`)** Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
| Issue | Tool | Gitea API | Add to tool input schema:
|-------|------|-----------| ```json
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | "archived": {
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | "type": "boolean",
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | "description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
**Batch 3 — can wait** Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
| Issue | Tool | Note | New test cases to add in `repo_update_test.go`:
|-------|------|------| - `TestRepoUpdateTool_Archive` — happy path with confirm
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | - `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | - `TestRepoUpdateTool_SetTemplate` — no confirm needed
### How to add a tool (pattern) #### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Every tool = 4 files following `internal/tools/repo_get.go` exactly: Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call() Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer the tool resolves it internally.
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules: New test case: `TestCreateProjectFromTemplate_AgentTemplate`
- 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 #### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
New tools require these additional Gitea token scopes: There is a loop bug where all file entries in the response contain the same diff
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create (the first file's diff is reused for every subsequent file). Find the loop and
- `delete_repo` — repo_delete ensure each iteration reads and assigns the correct diff for its own file.
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
If scopes are missing, update token in Gitea settings before running tests. a distinct diff.
### Definition of done ### Definition of done
- `task check` passes (all tools, all batches) - [ ] `task check` passes
- Each new tool manually callable via `claude mcp call` - [ ] `repo_update` accepts `archived` and `template` params
- PR #1 (batch 1) merged before starting batch 2 - [ ] `archived=true` requires `confirm=<repo name>`
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed - [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.

View File

@@ -41,9 +41,6 @@ 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
@@ -57,7 +54,6 @@ 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.
@@ -69,7 +65,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, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure ## Infrastructure
@@ -219,6 +215,7 @@ Key skills:
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description` - Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what* - PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -256,70 +253,100 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask 3. If unsure about a convention, check `DECISIONS.md` or ask
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. Always work on a feature branch and open a PR — never push directly to main
7. 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) ## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
### Context ### Context
This sprint implements new MCP tools needed for `hyperguild new-project` — The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
the automated project creation flow triggered from claude.ai. See brain knowledge repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` create_project_from_template) was implemented and pushed directly to main.
for full background.
### Issues to implement (priority order) This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** ### Issues to fix (all three in one PR: `fix/v02-patch`)
| Issue | Tool | Gitea API | #### #12 — repo_update: add `archived` and `template` fields
|-------|------|-----------| **File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | **File:** `internal/tools/repo_update.go` → input schema + args struct
| #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`)** Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
| Issue | Tool | Gitea API | Add to tool input schema:
|-------|------|-----------| ```json
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | "archived": {
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | "type": "boolean",
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | "description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
**Batch 3 — can wait** Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
| Issue | Tool | Note | New test cases to add in `repo_update_test.go`:
|-------|------|------| - `TestRepoUpdateTool_Archive` — happy path with confirm
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | - `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | - `TestRepoUpdateTool_SetTemplate` — no confirm needed
### How to add a tool (pattern) #### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Every tool = 4 files following `internal/tools/repo_get.go` exactly: Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call() Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer the tool resolves it internally.
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules: New test case: `TestCreateProjectFromTemplate_AgentTemplate`
- 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 #### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
New tools require these additional Gitea token scopes: There is a loop bug where all file entries in the response contain the same diff
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create (the first file's diff is reused for every subsequent file). Find the loop and
- `delete_repo` — repo_delete ensure each iteration reads and assigns the correct diff for its own file.
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
If scopes are missing, update token in Gitea settings before running tests. a distinct diff.
### Definition of done ### Definition of done
- `task check` passes (all tools, all batches) - [ ] `task check` passes
- Each new tool manually callable via `claude mcp call` - [ ] `repo_update` accepts `archived` and `template` params
- PR #1 (batch 1) merged before starting batch 2 - [ ] `archived=true` requires `confirm=<repo name>`
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed - [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
--- ---

View File

@@ -39,9 +39,6 @@ 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
@@ -55,7 +52,6 @@ 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.
@@ -67,7 +63,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, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure ## Infrastructure
@@ -217,6 +213,7 @@ Key skills:
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description` - Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what* - PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -254,68 +251,98 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask 3. If unsure about a convention, check `DECISIONS.md` or ask
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. Always work on a feature branch and open a PR — never push directly to main
7. 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) ## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
### Context ### Context
This sprint implements new MCP tools needed for `hyperguild new-project` — The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
the automated project creation flow triggered from claude.ai. See brain knowledge repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` create_project_from_template) was implemented and pushed directly to main.
for full background.
### Issues to implement (priority order) This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** ### Issues to fix (all three in one PR: `fix/v02-patch`)
| Issue | Tool | Gitea API | #### #12 — repo_update: add `archived` and `template` fields
|-------|------|-----------| **File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | **File:** `internal/tools/repo_update.go` → input schema + args struct
| #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`)** Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
| Issue | Tool | Gitea API | Add to tool input schema:
|-------|------|-----------| ```json
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | "archived": {
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | "type": "boolean",
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | "description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
**Batch 3 — can wait** Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
| Issue | Tool | Note | New test cases to add in `repo_update_test.go`:
|-------|------|------| - `TestRepoUpdateTool_Archive` — happy path with confirm
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | - `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | - `TestRepoUpdateTool_SetTemplate` — no confirm needed
### How to add a tool (pattern) #### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Every tool = 4 files following `internal/tools/repo_get.go` exactly: Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call() Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer the tool resolves it internally.
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules: New test case: `TestCreateProjectFromTemplate_AgentTemplate`
- 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 #### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
New tools require these additional Gitea token scopes: There is a loop bug where all file entries in the response contain the same diff
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create (the first file's diff is reused for every subsequent file). Find the loop and
- `delete_repo` — repo_delete ensure each iteration reads and assigns the correct diff for its own file.
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
If scopes are missing, update token in Gitea settings before running tests. a distinct diff.
### Definition of done ### Definition of done
- `task check` passes (all tools, all batches) - [ ] `task check` passes
- Each new tool manually callable via `claude mcp call` - [ ] `repo_update` accepts `archived` and `template` params
- PR #1 (batch 1) merged before starting batch 2 - [ ] `archived=true` requires `confirm=<repo name>`
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed - [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.

127
AGENTS.md
View File

@@ -36,9 +36,6 @@ 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,7 +49,6 @@ 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.
@@ -64,7 +60,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, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure ## Infrastructure
@@ -214,6 +210,7 @@ Key skills:
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description` - Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what* - PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -251,68 +248,98 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask 3. If unsure about a convention, check `DECISIONS.md` or ask
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. Always work on a feature branch and open a PR — never push directly to main
7. 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) ## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
### Context ### Context
This sprint implements new MCP tools needed for `hyperguild new-project` The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
the automated project creation flow triggered from claude.ai. See brain knowledge repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` create_project_from_template) was implemented and pushed directly to main.
for full background.
### Issues to implement (priority order) This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** ### Issues to fix (all three in one PR: `fix/v02-patch`)
| Issue | Tool | Gitea API | #### #12 — repo_update: add `archived` and `template` fields
|-------|------|-----------| **File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | **File:** `internal/tools/repo_update.go` → input schema + args struct
| #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`)** Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
| Issue | Tool | Gitea API | Add to tool input schema:
|-------|------|-----------| ```json
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | "archived": {
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | "type": "boolean",
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | "description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
**Batch 3 — can wait** Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
| Issue | Tool | Note | New test cases to add in `repo_update_test.go`:
|-------|------|------| - `TestRepoUpdateTool_Archive` — happy path with confirm
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | - `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | - `TestRepoUpdateTool_SetTemplate` — no confirm needed
### How to add a tool (pattern) #### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Every tool = 4 files following `internal/tools/repo_get.go` exactly: Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call() Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer the tool resolves it internally.
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules: New test case: `TestCreateProjectFromTemplate_AgentTemplate`
- 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 #### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
New tools require these additional Gitea token scopes: There is a loop bug where all file entries in the response contain the same diff
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create (the first file's diff is reused for every subsequent file). Find the loop and
- `delete_repo` — repo_delete ensure each iteration reads and assigns the correct diff for its own file.
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
If scopes are missing, update token in Gitea settings before running tests. a distinct diff.
### Definition of done ### Definition of done
- `task check` passes (all tools, all batches) - [ ] `task check` passes
- Each new tool manually callable via `claude mcp call` - [ ] `repo_update` accepts `archived` and `template` params
- PR #1 (batch 1) merged before starting batch 2 - [ ] `archived=true` requires `confirm=<repo name>`
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed - [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.

121
CLAUDE.md
View File

@@ -39,6 +39,7 @@
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description` - Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what* - PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -76,68 +77,98 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask 3. If unsure about a convention, check `DECISIONS.md` or ask
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. Always work on a feature branch and open a PR — never push directly to main
7. 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) ## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
### Context ### Context
This sprint implements new MCP tools needed for `hyperguild new-project` The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
the automated project creation flow triggered from claude.ai. See brain knowledge repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` create_project_from_template) was implemented and pushed directly to main.
for full background.
### Issues to implement (priority order) This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** ### Issues to fix (all three in one PR: `fix/v02-patch`)
| Issue | Tool | Gitea API | #### #12 — repo_update: add `archived` and `template` fields
|-------|------|-----------| **File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | **File:** `internal/tools/repo_update.go` → input schema + args struct
| #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`)** Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
| Issue | Tool | Gitea API | Add to tool input schema:
|-------|------|-----------| ```json
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | "archived": {
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | "type": "boolean",
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | "description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
**Batch 3 — can wait** Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
| Issue | Tool | Note | New test cases to add in `repo_update_test.go`:
|-------|------|------| - `TestRepoUpdateTool_Archive` — happy path with confirm
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | - `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | - `TestRepoUpdateTool_SetTemplate` — no confirm needed
### How to add a tool (pattern) #### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Every tool = 4 files following `internal/tools/repo_get.go` exactly: Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call() Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer the tool resolves it internally.
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules: New test case: `TestCreateProjectFromTemplate_AgentTemplate`
- 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 #### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
New tools require these additional Gitea token scopes: There is a loop bug where all file entries in the response contain the same diff
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create (the first file's diff is reused for every subsequent file). Find the loop and
- `delete_repo` — repo_delete ensure each iteration reads and assigns the correct diff for its own file.
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
If scopes are missing, update token in Gitea settings before running tests. a distinct diff.
### Definition of done ### Definition of done
- `task check` passes (all tools, all batches) - [ ] `task check` passes
- Each new tool manually callable via `claude mcp call` - [ ] `repo_update` accepts `archived` and `template` params
- PR #1 (batch 1) merged before starting batch 2 - [ ] `archived=true` requires `confirm=<repo name>`
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed - [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.

View File

@@ -65,6 +65,9 @@ func main() {
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow)) reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow)) reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTopicsUpdate(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

@@ -12,6 +12,20 @@ type Issue struct {
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) {

View File

@@ -52,6 +52,59 @@ func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive
return &t, nil 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 { func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo) path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
body, err := json.Marshal(map[string][]string{"topics": topics}) body, err := json.Marshal(map[string][]string{"topics": topics})
@@ -163,6 +216,8 @@ type UpdateRepoArgs struct {
Private *bool `json:"private,omitempty"` Private *bool `json:"private,omitempty"`
Website *string `json:"website,omitempty"` Website *string `json:"website,omitempty"`
DefaultBranch *string `json:"default_branch,omitempty"` DefaultBranch *string `json:"default_branch,omitempty"`
Archived *bool `json:"archived,omitempty"`
Template *bool `json:"template,omitempty"`
} }
func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) { func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) {
@@ -200,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
} }
return &r, nil return &r, nil
} }

View File

@@ -136,6 +136,40 @@ func TestUpdateTopics(t *testing.T) {
require.NoError(t, err) 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

@@ -45,14 +45,15 @@ 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 the template, applying placeholder substitutions to known files.", 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).",
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"]
}`), }`),
@@ -64,6 +65,7 @@ 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 {
@@ -91,13 +93,20 @@ 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, t.templateName) tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
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, t.templateName, gitea.ErrValidation) return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
} }
// Verify destination doesn't already exist. // Verify destination doesn't already exist.
@@ -108,7 +117,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, t.templateName, gitea.GenerateFromTemplateArgs{ newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, tmplName, gitea.GenerateFromTemplateArgs{
Owner: args.Owner, Owner: args.Owner,
Name: args.Name, Name: args.Name,
Description: args.Description, Description: args.Description,

View File

@@ -122,6 +122,62 @@ 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(

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

@@ -143,7 +143,13 @@ func splitUnifiedDiff(d []byte) map[string][]byte {
flush := func() { flush := func() {
if currentFile != "" { if currentFile != "" {
m[currentFile] = current.Bytes() // Copy: bytes.Buffer.Bytes() returns the internal slice,
// 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()
} }
} }

View File

@@ -97,6 +97,47 @@ 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"}

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,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

@@ -22,16 +22,20 @@ 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 (description, visibility, default branch, website).", Description: "Update repository metadata (description, visibility, default branch, website, archived, template). " +
"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"},
"description":{"type":"string"}, "description":{"type":"string"},
"private":{"type":"boolean"}, "private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."},
"website":{"type":"string"}, "website":{"type":"string","description":"Homepage URL"},
"default_branch":{"type":"string"}, "default_branch":{"type":"string","description":"Rename the default branch"},
"archived":{"type":"boolean","description":"Mark repo as archived (read-only)."},
"template":{"type":"boolean","description":"Toggle template-repo flag"},
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."} "confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
}, },
"required":["owner","name"] "required":["owner","name"]
@@ -42,10 +46,12 @@ 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"`
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"` DefaultBranch *string `json:"default_branch,omitempty"`
Archived *bool `json:"archived,omitempty"`
Template *bool `json:"template,omitempty"`
Confirm string `json:"confirm"` Confirm string `json:"confirm"`
} }
@@ -57,17 +63,26 @@ 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
} }
// Making a repo public is a significant action — require explicit confirmation. // Making a repo public is a significant action — require explicit confirmation.
if args.Private != nil && !*args.Private { if args.Private != nil && !*args.Private {
if args.Confirm != args.Name { if args.Confirm != args.Name {
return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name) return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name)
} }
} }
if args.Description == nil && args.Private == nil && args.Website == nil &&
args.DefaultBranch == nil && args.Archived == 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{ r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
Description: args.Description, Description: args.Description,
Private: args.Private, Private: args.Private,
Website: args.Website, Website: args.Website,
DefaultBranch: args.DefaultBranch, DefaultBranch: args.DefaultBranch,
Archived: args.Archived,
Template: args.Template,
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -3,6 +3,7 @@ package tools_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -14,43 +15,139 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestRepoUpdateTool(t *testing.T) { func newRepoUpdateTool(srvURL string) *tools.RepoUpdate {
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) {
assert.Equal(t, http.MethodPatch, r.Method) require.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path) require.Equal(t, "/api/v1/repos/mathias/old-svc", 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":"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"}`)) _, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`))
})) }))
defer srv.Close() defer srv.Close()
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) tool := newRepoUpdateTool(srv.URL)
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`)) result, err := tool.Call(context.Background(), json.RawMessage(
`{"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)
} }
func TestRepoUpdateTool_MakePublicRequiresConfirm(t *testing.T) { // TestRepoUpdateMultipleFields: set description + template flag in one call.
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) func TestRepoUpdateMultipleFields(t *testing.T) {
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`)) var patchedBody []byte
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)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
// TestRepoUpdateMakePublic: private=false requires confirm=<repo name> as a safety
// gate (kept from main #21 during the v02-patch merge). With confirm matching, the
// patch goes through.
func TestRepoUpdateMakePublic(t *testing.T) {
var patchedBody []byte
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":"open-repo","full_name":"mathias/open-repo","private":false}`))
}))
defer srv.Close()
tool := newRepoUpdateTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"mathias","name":"open-repo","private":false,"confirm":"open-repo"}`,
))
require.NoError(t, err)
var sent map[string]any
require.NoError(t, json.Unmarshal(patchedBody, &sent))
assert.Equal(t, false, sent["private"])
}
// TestRepoUpdateMakePublicWithoutConfirm: confirm gate blocks private=false without confirmation.
func TestRepoUpdateMakePublicWithoutConfirm(t *testing.T) {
tool := tools.NewRepoUpdate(
gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
)
_, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"mathias","name":"open-repo","private":false}`,
))
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "confirm") assert.Contains(t, err.Error(), "confirm")
} }
func TestRepoUpdateTool_MakePublicWithConfirm(t *testing.T) { // TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { func TestRepoUpdateAllowlistRejects(t *testing.T) {
w.Header().Set("Content-Type", "application/json") tool := tools.NewRepoUpdate(
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`)) gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
)
_, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"evil","name":"some-repo","archived":true}`,
))
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() defer srv.Close()
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) tool := newRepoUpdateTool(srv.URL)
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`)) _, err := tool.Call(context.Background(), json.RawMessage(
require.NoError(t, err) `{"owner":"mathias","name":"some-repo","archived":true}`,
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) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrUpstream)
} }