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
This commit was merged in pull request #26.
This commit is contained in:
2026-05-16 22:03:29 +00:00
12 changed files with 653 additions and 287 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

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

@@ -216,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) {
@@ -253,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
} }
return &r, nil return &r, nil
} }

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

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

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