Compare commits
13 Commits
e2da495581
...
v0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
| e31fd3f023 | |||
|
|
3cccbfb8cb | ||
| 3648373333 | |||
|
|
eeefc626ed | ||
|
|
5545d6ab4b | ||
|
|
9013c8ff9c | ||
|
|
f26f922c96 | ||
| a414222610 | |||
| 3b490271ef | |||
|
|
d4dddbdb6c | ||
| a69d3a8b76 | |||
|
|
5f3ad99122 | ||
| 2c94de7b59 |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
127
.cursorrules
127
.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.
|
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
127
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.
|
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
121
CLAUDE.md
@@ -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.
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ func main() {
|
|||||||
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref str
|
|||||||
if err := MapStatus(status, body); err != nil {
|
if err := MapStatus(status, body); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Array response means path is a directory — guide caller to dir_list.
|
||||||
|
if len(body) > 0 && body[0] == '[' {
|
||||||
|
return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path)
|
||||||
|
}
|
||||||
var fc FileContents
|
var fc FileContents
|
||||||
if err := json.Unmarshal(body, &fc); err != nil {
|
if err := json.Unmarshal(body, &fc); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -7,11 +7,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Labels []Label `json:"labels"`
|
||||||
|
Assignees []User `json:"assignees"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Label struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Login string `json:"login"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateIssueArgs struct {
|
type CreateIssueArgs struct {
|
||||||
@@ -22,6 +36,22 @@ type CreateIssueArgs struct {
|
|||||||
Milestone int64 `json:"milestone,omitempty"`
|
Milestone int64 `json:"milestone,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
||||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||||
payload, err := json.Marshal(args)
|
payload, err := json.Marshal(args)
|
||||||
|
|||||||
@@ -45,6 +45,37 @@ func TestCreateIssue(t *testing.T) {
|
|||||||
assert.Equal(t, "open", iss.State)
|
assert.Equal(t, "open", iss.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetIssue(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, iss.Number)
|
||||||
|
assert.Equal(t, "fix auth", iss.Title)
|
||||||
|
assert.Equal(t, "open", iss.State)
|
||||||
|
assert.Equal(t, 3, iss.Comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIssue_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.GetIssue(context.Background(), "o", "r", 999)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateIssueComment(t *testing.T) {
|
func TestCreateIssueComment(t *testing.T) {
|
||||||
var captured []byte
|
var captured []byte
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -18,6 +18,109 @@ type Repo struct {
|
|||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TreeEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"` // "blob" or "tree"
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tree struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Tree []TreeEntry `json:"tree"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref))
|
||||||
|
if recursive {
|
||||||
|
path += "?recursive=1"
|
||||||
|
}
|
||||||
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var t Tree
|
||||||
|
if err := json.Unmarshal(body, &t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Release struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReleaseArgs struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Draft bool `json:"draft,omitempty"`
|
||||||
|
Prerelease bool `json:"prerelease,omitempty"`
|
||||||
|
// Target branch or commit SHA for tag creation. Empty = repo default branch.
|
||||||
|
Target string `json:"target_commitish,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateRelease(ctx context.Context, owner, repo string, args CreateReleaseArgs) (*Release, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner, repo)
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PostJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Release
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo)
|
||||||
|
resp, status, err := c.DeleteJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
|
||||||
|
body, err := json.Marshal(map[string][]string{"topics": topics})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PutJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -113,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) {
|
||||||
@@ -150,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
|||||||
}
|
}
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,72 @@ func TestUpdateRepo(t *testing.T) {
|
|||||||
assert.Equal(t, "updated", r.Description)
|
assert.Equal(t, "updated", r.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTree(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||||
|
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "abc", tree.SHA)
|
||||||
|
require.Len(t, tree.Tree, 2)
|
||||||
|
assert.Equal(t, "README.md", tree.Tree[0].Path)
|
||||||
|
assert.Equal(t, "blob", tree.Tree[0].Type)
|
||||||
|
assert.Equal(t, int64(13), tree.Tree[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTopics(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPut, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRelease(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
|
||||||
|
TagName: "v1.0.0",
|
||||||
|
Name: "v1.0.0",
|
||||||
|
Body: "first release",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "v1.0.0", rel.TagName)
|
||||||
|
assert.Equal(t, "first release", rel.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRepo(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.DeleteRepo(context.Background(), "mathias", "infra")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
||||||
var hits int32
|
var hits int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -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"]
|
||||||
}`),
|
}`),
|
||||||
@@ -60,10 +61,11 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createProjectArgs struct {
|
type createProjectArgs 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"`
|
||||||
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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
|
|||||||
assert.Equal(t, "main", result["ref"])
|
assert.Equal(t, "main", result["ref"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Gitea returns an array when path is a directory
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "directory")
|
||||||
|
assert.Contains(t, err.Error(), "dir_list")
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileReadAllowlistRejects(t *testing.T) {
|
func TestFileReadAllowlistRejects(t *testing.T) {
|
||||||
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
||||||
|
|||||||
54
internal/tools/issue_get.go
Normal file
54
internal/tools/issue_get.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueGet struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
|
||||||
|
|
||||||
|
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_get",
|
||||||
|
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","name","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueGetArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueGetArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iss, err := t.c.GetIssue(ctx, args.Owner, args.Name, args.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(iss)
|
||||||
|
}
|
||||||
50
internal/tools/issue_get_test.go
Normal file
50
internal/tools/issue_get_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueGetTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"number":42`)
|
||||||
|
assert.Contains(t, string(out), `"title":"fix auth"`)
|
||||||
|
assert.Contains(t, string(out), `"comments":3`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueGetTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueGetAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
73
internal/tools/release_create.go
Normal file
73
internal/tools/release_create.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReleaseCreate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReleaseCreate(c *gitea.Client, a *allowlist.Allowlist) *ReleaseCreate {
|
||||||
|
return &ReleaseCreate{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReleaseCreate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "release_create",
|
||||||
|
Description: "Create a release (and tag if it doesn't exist) for a repository.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"tag_name":{"type":"string","description":"Tag to create or use, e.g. 'v1.0.0'."},
|
||||||
|
"release_name":{"type":"string","description":"Display name for the release."},
|
||||||
|
"body":{"type":"string","description":"Release notes / changelog."},
|
||||||
|
"draft":{"type":"boolean"},
|
||||||
|
"prerelease":{"type":"boolean"},
|
||||||
|
"target":{"type":"string","description":"Branch or commit SHA to tag. Defaults to repo default branch."}
|
||||||
|
},
|
||||||
|
"required":["owner","name","tag_name"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseCreateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
ReleaseName string `json:"release_name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args releaseCreateArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Name, gitea.CreateReleaseArgs{
|
||||||
|
TagName: args.TagName,
|
||||||
|
Name: args.ReleaseName,
|
||||||
|
Body: args.Body,
|
||||||
|
Draft: args.Draft,
|
||||||
|
Prerelease: args.Prerelease,
|
||||||
|
Target: args.Target,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(rel)
|
||||||
|
}
|
||||||
38
internal/tools/release_create_test.go
Normal file
38
internal/tools/release_create_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReleaseCreateTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"changelog","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewReleaseCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","tag_name":"v1.0.0","release_name":"v1.0.0","body":"changelog"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"tag_name":"v1.0.0"`)
|
||||||
|
assert.Contains(t, string(out), `"html_url"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCreateAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewReleaseCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","tag_name":"v1.0.0"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
59
internal/tools/repo_delete.go
Normal file
59
internal/tools/repo_delete.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoDelete struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoDelete(c *gitea.Client, a *allowlist.Allowlist) *RepoDelete {
|
||||||
|
return &RepoDelete{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoDelete) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "repo_delete",
|
||||||
|
Description: "Permanently delete a repository. Requires confirm=<repo name> to prevent accidents.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"confirm":{"type":"string","description":"Must equal the repo name exactly to proceed."}
|
||||||
|
},
|
||||||
|
"required":["owner","name","confirm"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoDeleteArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args repoDeleteArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Confirm != args.Name {
|
||||||
|
return nil, fmt.Errorf("repo_delete requires confirm=%q to match the repo name — got %q", args.Name, args.Confirm)
|
||||||
|
}
|
||||||
|
if err := t.c.DeleteRepo(ctx, args.Owner, args.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(map[string]string{"status": "deleted", "repo": args.Owner + "/" + args.Name})
|
||||||
|
}
|
||||||
52
internal/tools/repo_delete_test.go
Normal file
52
internal/tools/repo_delete_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepoDeleteTool_WithCorrectConfirm(t *testing.T) {
|
||||||
|
deleted := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
|
deleted = true
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"infra"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, deleted, "DELETE request must have been sent")
|
||||||
|
assert.Contains(t, string(out), "deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDeleteTool_WrongConfirmBlocked(t *testing.T) {
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"wrong"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDeleteTool_MissingConfirmBlocked(t *testing.T) {
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDeleteAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","confirm":"x"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
55
internal/tools/repo_topics_update.go
Normal file
55
internal/tools/repo_topics_update.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoTopicsUpdate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoTopicsUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoTopicsUpdate {
|
||||||
|
return &RepoTopicsUpdate{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoTopicsUpdate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "repo_topics_update",
|
||||||
|
Description: "Replace the topic list for a repository.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"topics":{"type":"array","items":{"type":"string"},"description":"Full replacement list. Send [] to clear all topics."}
|
||||||
|
},
|
||||||
|
"required":["owner","name","topics"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoTopicsUpdateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args repoTopicsUpdateArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.c.UpdateTopics(ctx, args.Owner, args.Name, args.Topics); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(map[string]any{"status": "updated", "topics": args.Topics})
|
||||||
|
}
|
||||||
35
internal/tools/repo_topics_update_test.go
Normal file
35
internal/tools/repo_topics_update_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepoTopicsUpdateTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPut, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoTopicsUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","topics":["go","mcp","gitops"]}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), "updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoTopicsUpdateAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewRepoTopicsUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","topics":[]}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
56
internal/tools/repo_tree.go
Normal file
56
internal/tools/repo_tree.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoTree struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoTree(c *gitea.Client, a *allowlist.Allowlist) *RepoTree {
|
||||||
|
return &RepoTree{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoTree) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "repo_tree",
|
||||||
|
Description: "Get the full recursive file tree for a repo ref (branch, tag, or SHA).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"ref":{"type":"string","description":"Branch, tag, or commit SHA."}
|
||||||
|
},
|
||||||
|
"required":["owner","name","ref"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoTreeArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args repoTreeArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, args.Ref, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(tree)
|
||||||
|
}
|
||||||
50
internal/tools/repo_tree_test.go
Normal file
50
internal/tools/repo_tree_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepoTreeTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||||
|
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi","size":0}],"truncated":false}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"sha":"abc"`)
|
||||||
|
assert.Contains(t, string(out), `"path":"README.md"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoTreeTool_DefaultsToRecursive(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"sha":"abc","tree":[],"truncated":false}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoTreeAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewRepoTree(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","ref":"main"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -21,17 +21,21 @@ 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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user