Compare commits
5 Commits
3cccbfb8cb
...
4122e1f2ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 4122e1f2ae | |||
|
|
d74b196db1 | ||
|
|
103194a11a | ||
|
|
4c87856aec | ||
|
|
b4176c1dec |
@@ -210,7 +210,6 @@ 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
|
||||||
@@ -248,98 +247,4 @@ 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. Always work on a feature branch and open a PR — never push directly to main
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
|
||||||
|
|
||||||
### Context
|
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
|
||||||
```json
|
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `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,7 +39,6 @@
|
|||||||
- 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
|
||||||
@@ -77,98 +76,4 @@ 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. Always work on a feature branch and open a PR — never push directly to main
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
|
||||||
|
|
||||||
### Context
|
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
|
||||||
```json
|
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `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.
|
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ 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
|
||||||
@@ -253,100 +252,6 @@ 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. Always work on a feature branch and open a PR — never push directly to main
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
|
||||||
|
|
||||||
### Context
|
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
|
||||||
```json
|
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
97
.cursorrules
97
.cursorrules
@@ -213,7 +213,6 @@ 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,98 +250,4 @@ 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. Always work on a feature branch and open a PR — never push directly to main
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
|
||||||
|
|
||||||
### Context
|
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
|
||||||
```json
|
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `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.
|
|
||||||
|
|||||||
97
AGENTS.md
97
AGENTS.md
@@ -210,7 +210,6 @@ 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
|
||||||
@@ -248,98 +247,4 @@ 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. Always work on a feature branch and open a PR — never push directly to main
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
|
||||||
|
|
||||||
### Context
|
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
|
||||||
```json
|
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `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.
|
|
||||||
|
|||||||
97
CLAUDE.md
97
CLAUDE.md
@@ -39,7 +39,6 @@
|
|||||||
- 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
|
||||||
@@ -77,98 +76,4 @@ 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. Always work on a feature branch and open a PR — never push directly to main
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
|
||||||
|
|
||||||
### Context
|
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
|
||||||
```json
|
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `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.
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func main() {
|
|||||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
||||||
@@ -60,14 +61,6 @@ func main() {
|
|||||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
||||||
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
|
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
|
||||||
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,10 +27,6 @@ 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,25 +7,11 @@ 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 {
|
||||||
@@ -36,22 +22,6 @@ 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,37 +45,6 @@ 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) {
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
package gitea
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PushMirror struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
RemoteName string `json:"remote_name"`
|
|
||||||
RemoteAddress string `json:"remote_address"`
|
|
||||||
Interval string `json:"interval"`
|
|
||||||
SyncOnCommit bool `json:"sync_on_commit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddPushMirrorArgs struct {
|
|
||||||
RemoteAddress string `json:"remote_address"`
|
|
||||||
RemoteUsername string `json:"remote_username,omitempty"`
|
|
||||||
RemotePassword string `json:"remote_password,omitempty"`
|
|
||||||
Interval string `json:"interval,omitempty"`
|
|
||||||
SyncOnCommit bool `json:"sync_on_commit,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) AddPushMirror(ctx context.Context, owner, repo string, args AddPushMirrorArgs) (*PushMirror, error) {
|
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
|
||||||
body, err := json.Marshal(args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, status, err := c.PostJSON(ctx, path, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := MapStatus(status, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var m PushMirror
|
|
||||||
if err := json.Unmarshal(resp, &m); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) ListPushMirrors(ctx context.Context, owner, repo string) ([]PushMirror, error) {
|
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
|
||||||
resp, status, err := c.GetJSON(ctx, path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := MapStatus(status, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var mirrors []PushMirror
|
|
||||||
if err := json.Unmarshal(resp, &mirrors); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mirrors, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) DeletePushMirror(ctx context.Context, owner, repo, mirrorName string) error {
|
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors/%s", owner, repo, mirrorName)
|
|
||||||
resp, status, err := c.DeleteJSON(ctx, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if status == 204 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return MapStatus(status, resp)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package gitea_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAddPushMirror(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
|
||||||
m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{
|
|
||||||
RemoteAddress: "https://github.com/mathias/infra.git",
|
|
||||||
RemoteUsername: "mathias",
|
|
||||||
RemotePassword: "secret",
|
|
||||||
Interval: "8h0m0s",
|
|
||||||
SyncOnCommit: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "mirror-github", m.RemoteName)
|
|
||||||
assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListPushMirrors(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodGet, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
|
||||||
mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, mirrors, 1)
|
|
||||||
assert.Equal(t, "mirror-github", mirrors[0].RemoteName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeletePushMirror(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodDelete, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
|
||||||
err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github")
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
@@ -18,109 +18,6 @@ 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
|
||||||
@@ -174,72 +71,6 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i
|
|||||||
return env.Data, nil
|
return env.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRepoArgs struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Private bool `json:"private,omitempty"`
|
|
||||||
AutoInit bool `json:"auto_init,omitempty"`
|
|
||||||
DefaultBranch string `json:"default_branch,omitempty"`
|
|
||||||
// Org, when non-empty, creates the repo under the named organisation.
|
|
||||||
// Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos.
|
|
||||||
Org string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) {
|
|
||||||
var path string
|
|
||||||
if args.Org != "" {
|
|
||||||
path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org)
|
|
||||||
} else {
|
|
||||||
path = "/api/v1/user/repos"
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, status, err := c.PostJSON(ctx, path, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := MapStatus(status, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var r Repo
|
|
||||||
if err := json.Unmarshal(resp, &r); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero.
|
|
||||||
type UpdateRepoArgs struct {
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
Private *bool `json:"private,omitempty"`
|
|
||||||
Website *string `json:"website,omitempty"`
|
|
||||||
DefaultBranch *string `json:"default_branch,omitempty"`
|
|
||||||
Archived *bool `json:"archived,omitempty"`
|
|
||||||
Template *bool `json:"template,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) {
|
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
|
||||||
body, err := json.Marshal(args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, status, err := c.PatchJSON(ctx, path, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := MapStatus(status, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var r Repo
|
|
||||||
if err := json.Unmarshal(resp, &r); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
body, status, err := c.GetJSON(ctx, path)
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
@@ -256,3 +87,33 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditRepoArgs carries optional fields for PATCH /api/v1/repos/{owner}/{name}.
|
||||||
|
// Pointer fields let the caller omit unset values from the wire payload, so the
|
||||||
|
// server only patches what was explicitly requested.
|
||||||
|
type EditRepoArgs struct {
|
||||||
|
Archived *bool `json:"archived,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Private *bool `json:"private,omitempty"`
|
||||||
|
Website *string `json:"website,omitempty"`
|
||||||
|
Template *bool `json:"template,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) EditRepo(ctx context.Context, owner, name string, args EditRepoArgs) (*Repo, error) {
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal edit args: %w", err)
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
|
resp, status, err := c.PatchJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,129 +47,6 @@ func TestListRepos(t *testing.T) {
|
|||||||
assert.Equal(t, "main", repos[0].DefaultBranch)
|
assert.Equal(t, "main", repos[0].DefaultBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateRepo_User(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
|
||||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
|
||||||
Name: "infra",
|
|
||||||
Private: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "mathias/infra", r.FullName)
|
|
||||||
assert.Equal(t, "main", r.DefaultBranch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateRepo_Org(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
|
||||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
|
||||||
Name: "infra",
|
|
||||||
Org: "hyperguild",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "hyperguild/infra", r.FullName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRepo(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodPatch, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
desc := "updated"
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
|
||||||
r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{
|
|
||||||
Description: &desc,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "updated", r.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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) {
|
||||||
|
|||||||
@@ -57,21 +57,6 @@ 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"}`))
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RepoCreate struct {
|
|
||||||
c *gitea.Client
|
|
||||||
a *allowlist.Allowlist
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRepoCreate(c *gitea.Client, a *allowlist.Allowlist) *RepoCreate {
|
|
||||||
return &RepoCreate{c: c, a: a}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RepoCreate) Descriptor() registry.ToolDescriptor {
|
|
||||||
return registry.ToolDescriptor{
|
|
||||||
Name: "repo_create",
|
|
||||||
Description: "Create a repository for the authenticated user or an organisation.",
|
|
||||||
InputSchema: json.RawMessage(`{
|
|
||||||
"type":"object",
|
|
||||||
"properties":{
|
|
||||||
"owner":{"type":"string","description":"Username or org name (used for allowlist check)."},
|
|
||||||
"name":{"type":"string","description":"Repository name."},
|
|
||||||
"description":{"type":"string"},
|
|
||||||
"private":{"type":"boolean","description":"Create as private. Default false."},
|
|
||||||
"auto_init":{"type":"boolean","description":"Initialise with README."},
|
|
||||||
"default_branch":{"type":"string","description":"Default branch name. Default 'main'."},
|
|
||||||
"is_org":{"type":"boolean","description":"When true, create under the organisation named in 'owner'."}
|
|
||||||
},
|
|
||||||
"required":["owner","name"]
|
|
||||||
}`),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type repoCreateArgs struct {
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Private bool `json:"private"`
|
|
||||||
AutoInit bool `json:"auto_init"`
|
|
||||||
DefaultBranch string `json:"default_branch"`
|
|
||||||
IsOrg bool `json:"is_org"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
|
||||||
var args repoCreateArgs
|
|
||||||
if err := parseArgs(raw, &args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
createArgs := gitea.CreateRepoArgs{
|
|
||||||
Name: args.Name,
|
|
||||||
Description: args.Description,
|
|
||||||
Private: args.Private,
|
|
||||||
AutoInit: args.AutoInit,
|
|
||||||
DefaultBranch: args.DefaultBranch,
|
|
||||||
}
|
|
||||||
if args.IsOrg {
|
|
||||||
createArgs.Org = args.Owner
|
|
||||||
}
|
|
||||||
r, err := t.c.CreateRepo(ctx, createArgs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return textOK(r)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package tools_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRepoCreateTool_User(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
|
||||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":true}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
|
|
||||||
assert.Contains(t, string(out), `"clone_url"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepoCreateTool_Org(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"hyperguild"}))
|
|
||||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"hyperguild","name":"infra","is_org":true}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(out), `"full_name":"hyperguild/infra"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepoCreateAllowlistRejects(t *testing.T) {
|
|
||||||
tool := tools.NewRepoCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RepoMirrorPush struct {
|
|
||||||
c *gitea.Client
|
|
||||||
a *allowlist.Allowlist
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush {
|
|
||||||
return &RepoMirrorPush{c: c, a: a}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
|
|
||||||
return registry.ToolDescriptor{
|
|
||||||
Name: "repo_mirror_push",
|
|
||||||
Description: "Manage push mirrors for a repository: add, list, or delete.",
|
|
||||||
InputSchema: json.RawMessage(`{
|
|
||||||
"type":"object",
|
|
||||||
"properties":{
|
|
||||||
"owner":{"type":"string"},
|
|
||||||
"name":{"type":"string"},
|
|
||||||
"action":{"type":"string","enum":["add","list","delete"]},
|
|
||||||
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
|
|
||||||
"remote_username":{"type":"string"},
|
|
||||||
"remote_password":{"type":"string","description":"Never logged or returned."},
|
|
||||||
"interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."},
|
|
||||||
"sync_on_commit":{"type":"boolean"},
|
|
||||||
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
|
|
||||||
},
|
|
||||||
"required":["owner","name","action"]
|
|
||||||
}`),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type repoMirrorPushArgs struct {
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
RemoteAddress string `json:"remote_address"`
|
|
||||||
RemoteUsername string `json:"remote_username"`
|
|
||||||
RemotePassword string `json:"remote_password"`
|
|
||||||
Interval string `json:"interval"`
|
|
||||||
SyncOnCommit bool `json:"sync_on_commit"`
|
|
||||||
MirrorName string `json:"mirror_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeMirror omits remote_password so it is never returned to the caller.
|
|
||||||
type safeMirror struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
RemoteName string `json:"remote_name"`
|
|
||||||
RemoteAddress string `json:"remote_address"`
|
|
||||||
Interval string `json:"interval"`
|
|
||||||
SyncOnCommit bool `json:"sync_on_commit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toSafeMirror(m *gitea.PushMirror) safeMirror {
|
|
||||||
return safeMirror{
|
|
||||||
ID: m.ID,
|
|
||||||
RemoteName: m.RemoteName,
|
|
||||||
RemoteAddress: m.RemoteAddress,
|
|
||||||
Interval: m.Interval,
|
|
||||||
SyncOnCommit: m.SyncOnCommit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
|
||||||
var args repoMirrorPushArgs
|
|
||||||
if err := parseArgs(raw, &args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch args.Action {
|
|
||||||
case "add":
|
|
||||||
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Name, gitea.AddPushMirrorArgs{
|
|
||||||
RemoteAddress: args.RemoteAddress,
|
|
||||||
RemoteUsername: args.RemoteUsername,
|
|
||||||
RemotePassword: args.RemotePassword,
|
|
||||||
Interval: args.Interval,
|
|
||||||
SyncOnCommit: args.SyncOnCommit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return textOK(toSafeMirror(m))
|
|
||||||
case "list":
|
|
||||||
mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
safe := make([]safeMirror, len(mirrors))
|
|
||||||
for i := range mirrors {
|
|
||||||
safe[i] = toSafeMirror(&mirrors[i])
|
|
||||||
}
|
|
||||||
return textOK(safe)
|
|
||||||
case "delete":
|
|
||||||
if args.MirrorName == "" {
|
|
||||||
return nil, fmt.Errorf("mirror_name is required for action=delete")
|
|
||||||
}
|
|
||||||
if err := t.c.DeletePushMirror(ctx, args.Owner, args.Name, args.MirrorName); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName})
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package tools_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRepoMirrorPushTool_Add(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
|
||||||
out, err := tool.Call(context.Background(), json.RawMessage(`{
|
|
||||||
"owner":"mathias","name":"infra","action":"add",
|
|
||||||
"remote_address":"https://github.com/mathias/infra.git",
|
|
||||||
"remote_username":"mathias","remote_password":"secret",
|
|
||||||
"interval":"8h0m0s","sync_on_commit":true
|
|
||||||
}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
// password must never appear in output
|
|
||||||
assert.NotContains(t, string(out), "secret")
|
|
||||||
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepoMirrorPushTool_List(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodGet, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
|
||||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"list"}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepoMirrorPushTool_Delete(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodDelete, r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
|
||||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete","mirror_name":"mirror-github"}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(out), "deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepoMirrorPushTool_DeleteRequiresMirrorName(t *testing.T) {
|
|
||||||
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete"}`))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "mirror_name")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepoMirrorPushTool_AllowlistRejects(t *testing.T) {
|
|
||||||
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","action":"list"}`))
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -88,9 +88,8 @@ func TestRepoUpdateNoFieldsRejected(t *testing.T) {
|
|||||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRepoUpdateMakePublic: private=false requires confirm=<repo name> as a safety
|
// TestRepoUpdateMakePublic: explicit private=false is allowed; wire payload carries the false.
|
||||||
// gate (kept from main #21 during the v02-patch merge). With confirm matching, the
|
// (The destructive nature is warned about in the tool description, not blocked by the tool.)
|
||||||
// patch goes through.
|
|
||||||
func TestRepoUpdateMakePublic(t *testing.T) {
|
func TestRepoUpdateMakePublic(t *testing.T) {
|
||||||
var patchedBody []byte
|
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) {
|
||||||
@@ -102,7 +101,7 @@ func TestRepoUpdateMakePublic(t *testing.T) {
|
|||||||
|
|
||||||
tool := newRepoUpdateTool(srv.URL)
|
tool := newRepoUpdateTool(srv.URL)
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
`{"owner":"mathias","name":"open-repo","private":false,"confirm":"open-repo"}`,
|
`{"owner":"mathias","name":"open-repo","private":false}`,
|
||||||
))
|
))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -111,19 +110,6 @@ func TestRepoUpdateMakePublic(t *testing.T) {
|
|||||||
assert.Equal(t, false, sent["private"])
|
assert.Equal(t, false, sent["private"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRepoUpdateMakePublicWithoutConfirm: confirm gate blocks private=false without confirmation.
|
|
||||||
func TestRepoUpdateMakePublicWithoutConfirm(t *testing.T) {
|
|
||||||
tool := tools.NewRepoUpdate(
|
|
||||||
gitea.NewClient("http://unused", ""),
|
|
||||||
allowlist.New([]string{"mathias"}),
|
|
||||||
)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
||||||
`{"owner":"mathias","name":"open-repo","private":false}`,
|
|
||||||
))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "confirm")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
|
// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
|
||||||
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
||||||
tool := tools.NewRepoUpdate(
|
tool := tools.NewRepoUpdate(
|
||||||
|
|||||||
Reference in New Issue
Block a user