diff --git a/.aider.conventions.md b/.aider.conventions.md index 230796d..a48af53 100644 --- a/.aider.conventions.md +++ b/.aider.conventions.md @@ -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. Loop — implement, verify, refine — until those criteria are met. Don't claim 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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## 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) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -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 - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, 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 @@ -214,6 +210,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - 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 - 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 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +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 -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### 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 | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"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 | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `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/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### 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: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +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` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `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. diff --git a/.context/system-prompt.txt b/.context/system-prompt.txt index 61d4351..23b35d4 100644 --- a/.context/system-prompt.txt +++ b/.context/system-prompt.txt @@ -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. Loop — implement, verify, refine — until those criteria are met. Don't claim 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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## 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) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -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 - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, 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 @@ -219,6 +215,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - 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 - 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 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +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 -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### 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 | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"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 | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `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/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### 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: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +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` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `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. --- diff --git a/.cursorrules b/.cursorrules index 1e224a4..cc7d254 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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. Loop — implement, verify, refine — until those criteria are met. Don't claim 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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## 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) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -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 - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, 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 @@ -217,6 +213,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - 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 - 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 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +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 -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### 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 | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"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 | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `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/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### 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: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +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` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `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. diff --git a/AGENTS.md b/AGENTS.md index 230796d..a48af53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. Loop — implement, verify, refine — until those criteria are met. Don't claim 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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## 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) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -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 - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, 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 @@ -214,6 +210,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - 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 - 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 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +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 -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### 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 | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"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 | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `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/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### 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: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +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` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `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. diff --git a/CLAUDE.md b/CLAUDE.md index 934bde5..64260d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - 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 - 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 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +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 -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### 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 | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"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 | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `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/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### 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: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +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` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `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. diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 46b7d9d..c899e2b 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -216,6 +216,8 @@ type UpdateRepoArgs struct { Private *bool `json:"private,omitempty"` Website *string `json:"website,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) { @@ -253,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) } return &r, nil } + diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index 0f548aa..f8df046 100644 --- a/internal/tools/create_project_from_template.go +++ b/internal/tools/create_project_from_template.go @@ -45,14 +45,15 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ 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(`{ "type":"object", "properties":{ "owner":{"type":"string"}, "name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"}, "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"] }`), @@ -60,10 +61,11 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { } type createProjectArgs struct { - Owner string `json:"owner"` - Name string `json:"name"` - Description string `json:"description"` - Private bool `json:"private"` + Owner string `json:"owner"` + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` + TemplateName string `json:"template_name"` } 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) } + // 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. - tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName) + tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName) if err != nil { return nil, fmt.Errorf("template lookup: %w", err) } 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. @@ -108,7 +117,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag } // 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, Name: args.Name, Description: args.Description, diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go index 5c34d92..a5de491 100644 --- a/internal/tools/create_project_from_template_test.go +++ b/internal/tools/create_project_from_template_test.go @@ -122,6 +122,62 @@ func TestCreateProjectHappyPath(t *testing.T) { 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. func TestCreateProjectNameRegexFailure(t *testing.T) { tool := tools.NewCreateProjectFromTemplate( diff --git a/internal/tools/pr_files_diff.go b/internal/tools/pr_files_diff.go index 0400802..12f131a 100644 --- a/internal/tools/pr_files_diff.go +++ b/internal/tools/pr_files_diff.go @@ -143,7 +143,13 @@ func splitUnifiedDiff(d []byte) map[string][]byte { flush := func() { 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() } } diff --git a/internal/tools/pr_files_diff_test.go b/internal/tools/pr_files_diff_test.go index 02bec0f..0e1a61a 100644 --- a/internal/tools/pr_files_diff_test.go +++ b/internal/tools/pr_files_diff_test.go @@ -97,6 +97,47 @@ func TestPRFilesDiffSmall(t *testing.T) { 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) { // One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines). fileNames := []string{"bigfile.go"} diff --git a/internal/tools/repo_update.go b/internal/tools/repo_update.go index 74d22e3..5201070 100644 --- a/internal/tools/repo_update.go +++ b/internal/tools/repo_update.go @@ -21,17 +21,21 @@ func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate { func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ - Name: "repo_update", - Description: "Update repository metadata (description, visibility, default branch, website).", + Name: "repo_update", + 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(`{ "type":"object", "properties":{ "owner":{"type":"string"}, "name":{"type":"string"}, "description":{"type":"string"}, - "private":{"type":"boolean"}, - "website":{"type":"string"}, - "default_branch":{"type":"string"}, + "private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."}, + "website":{"type":"string","description":"Homepage URL"}, + "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."} }, "required":["owner","name"] @@ -42,10 +46,12 @@ func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { type repoUpdateArgs struct { Owner string `json:"owner"` Name string `json:"name"` - Description *string `json:"description"` - Private *bool `json:"private"` - Website *string `json:"website"` - DefaultBranch *string `json:"default_branch"` + Description *string `json:"description,omitempty"` + Private *bool `json:"private,omitempty"` + Website *string `json:"website,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + Archived *bool `json:"archived,omitempty"` + Template *bool `json:"template,omitempty"` 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 { return nil, err } + // Making a repo public is a significant action — require explicit confirmation. if args.Private != nil && !*args.Private { if args.Confirm != args.Name { return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name) } } + + 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{ Description: args.Description, Private: args.Private, Website: args.Website, DefaultBranch: args.DefaultBranch, + Archived: args.Archived, + Template: args.Template, }) if err != nil { return nil, err diff --git a/internal/tools/repo_update_test.go b/internal/tools/repo_update_test.go index ff930cc..889f92e 100644 --- a/internal/tools/repo_update_test.go +++ b/internal/tools/repo_update_test.go @@ -3,6 +3,7 @@ package tools_test import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -14,43 +15,139 @@ import ( "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) { - assert.Equal(t, http.MethodPatch, r.Method) - assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path) + require.Equal(t, http.MethodPatch, r.Method) + 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.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() - tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) - out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`)) + tool := newRepoUpdateTool(srv.URL) + result, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"old-svc","archived":true}`, + )) 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) { - tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) - _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`)) +// TestRepoUpdateMultipleFields: set description + template flag in one call. +func TestRepoUpdateMultipleFields(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":"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= 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) assert.Contains(t, err.Error(), "confirm") } -func TestRepoUpdateTool_MakePublicWithConfirm(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`)) +// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call. +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":"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() - tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) - out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`)) - require.NoError(t, err) - assert.Contains(t, string(out), `"full_name":"mathias/infra"`) -} - -func TestRepoUpdateAllowlistRejects(t *testing.T) { - tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) - _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`)) + tool := newRepoUpdateTool(srv.URL) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"some-repo","archived":true}`, + )) require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrUpstream) }