3 Commits

Author SHA1 Message Date
a414222610 docs: update sprint to v0.2 patch — fixes #12, #24, #25
All checks were successful
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Successful in 3s
2026-05-16 20:43:29 +00:00
3b490271ef Merge pull request 'feat(tools): issue_get, release_create, repo_delete (#11, #17, #20)' (#23) from feat/batch-3 into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
2026-05-15 12:00:09 +00:00
Mathias Bergqvist
d4dddbdb6c feat(tools): issue_get, release_create, repo_delete (#11, #17, #20)
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
issue_get: GET /repos/{owner}/{repo}/issues/{number} — full issue with labels, assignees, comment count
release_create: POST /repos/{owner}/{repo}/releases — create release and tag in one call
repo_delete: DELETE /repos/{owner}/{repo} — confirm=<repo name> required, blocks accidents
2026-05-15 13:59:06 +02:00
12 changed files with 558 additions and 50 deletions

View File

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

View File

@@ -65,6 +65,9 @@ func main() {
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{
Registry: reg,

View File

@@ -12,6 +12,20 @@ type Issue struct {
Body string `json:"body"`
HTMLURL string `json:"html_url"`
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 {
@@ -22,6 +36,22 @@ type CreateIssueArgs struct {
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) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload, err := json.Marshal(args)

View File

@@ -45,6 +45,37 @@ func TestCreateIssue(t *testing.T) {
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) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -52,6 +52,59 @@ func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive
return &t, nil
}
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})

View File

@@ -136,6 +136,40 @@ func TestUpdateTopics(t *testing.T) {
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) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

View File

@@ -0,0 +1,54 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueGet struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_get",
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type issueGetArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
}
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueGetArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
iss, err := t.c.GetIssue(ctx, args.Owner, args.Name, args.Number)
if err != nil {
return nil, err
}
return textOK(iss)
}

View File

@@ -0,0 +1,50 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueGetTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
}))
defer srv.Close()
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":42`)
assert.Contains(t, string(out), `"title":"fix auth"`)
assert.Contains(t, string(out), `"comments":3`)
}
func TestIssueGetTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
require.Error(t, err)
}
func TestIssueGetAllowlistRejects(t *testing.T) {
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type ReleaseCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewReleaseCreate(c *gitea.Client, a *allowlist.Allowlist) *ReleaseCreate {
return &ReleaseCreate{c: c, a: a}
}
func (t *ReleaseCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "release_create",
Description: "Create a release (and tag if it doesn't exist) for a repository.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"tag_name":{"type":"string","description":"Tag to create or use, e.g. 'v1.0.0'."},
"release_name":{"type":"string","description":"Display name for the release."},
"body":{"type":"string","description":"Release notes / changelog."},
"draft":{"type":"boolean"},
"prerelease":{"type":"boolean"},
"target":{"type":"string","description":"Branch or commit SHA to tag. Defaults to repo default branch."}
},
"required":["owner","name","tag_name"]
}`),
}
}
type releaseCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
TagName string `json:"tag_name"`
ReleaseName string `json:"release_name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Target string `json:"target"`
}
func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args releaseCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Name, gitea.CreateReleaseArgs{
TagName: args.TagName,
Name: args.ReleaseName,
Body: args.Body,
Draft: args.Draft,
Prerelease: args.Prerelease,
Target: args.Target,
})
if err != nil {
return nil, err
}
return textOK(rel)
}

View File

@@ -0,0 +1,38 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReleaseCreateTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"changelog","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
}))
defer srv.Close()
tool := tools.NewReleaseCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","tag_name":"v1.0.0","release_name":"v1.0.0","body":"changelog"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"tag_name":"v1.0.0"`)
assert.Contains(t, string(out), `"html_url"`)
}
func TestReleaseCreateAllowlistRejects(t *testing.T) {
tool := tools.NewReleaseCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","tag_name":"v1.0.0"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,59 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoDelete struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoDelete(c *gitea.Client, a *allowlist.Allowlist) *RepoDelete {
return &RepoDelete{c: c, a: a}
}
func (t *RepoDelete) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_delete",
Description: "Permanently delete a repository. Requires confirm=<repo name> to prevent accidents.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"confirm":{"type":"string","description":"Must equal the repo name exactly to proceed."}
},
"required":["owner","name","confirm"]
}`),
}
}
type repoDeleteArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Confirm string `json:"confirm"`
}
func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoDeleteArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Confirm != args.Name {
return nil, fmt.Errorf("repo_delete requires confirm=%q to match the repo name — got %q", args.Name, args.Confirm)
}
if err := t.c.DeleteRepo(ctx, args.Owner, args.Name); err != nil {
return nil, err
}
return textOK(map[string]string{"status": "deleted", "repo": args.Owner + "/" + args.Name})
}

View File

@@ -0,0 +1,52 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoDeleteTool_WithCorrectConfirm(t *testing.T) {
deleted := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodDelete, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
deleted = true
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewRepoDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"infra"}`))
require.NoError(t, err)
assert.True(t, deleted, "DELETE request must have been sent")
assert.Contains(t, string(out), "deleted")
}
func TestRepoDeleteTool_WrongConfirmBlocked(t *testing.T) {
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"wrong"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "confirm")
}
func TestRepoDeleteTool_MissingConfirmBlocked(t *testing.T) {
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "confirm")
}
func TestRepoDeleteAllowlistRejects(t *testing.T) {
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","confirm":"x"}`))
require.Error(t, err)
}