From dc907fb7e0db81c0ca3fd6a2847f61cafd9faee3 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 18 May 2026 07:51:17 +0200 Subject: [PATCH] feat: issue_close + issue_reopen tools (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two MCP tools that PATCH /api/v1/repos/{owner}/{name}/issues/{number} with {"state":"closed"} or {"state":"open"}. Both use a shared SetIssueState helper on the gitea client. - internal/gitea/issues.go: SetIssueState method using the existing PatchJSON + MapStatus + json.Unmarshal pattern from GetIssue. - internal/tools/issue_close.go: IssueClose tool. owner+name+number args. Owner allowlist enforced. Returns the updated issue. Reversible via issue_reopen, classified LOW risk. - internal/tools/issue_reopen.go: mirror of IssueClose with state="open". Same risk profile. - Registered both tools in cmd/gitea-mcp/main.go. - Tests for both: success (asserts PATCH method, path, body), 404, and allowlist rejection — same shape as issue_get_test.go. Closes #30 --- cmd/gitea-mcp/main.go | 2 ++ internal/gitea/issues.go | 22 ++++++++++++ internal/tools/issue_close.go | 56 +++++++++++++++++++++++++++++ internal/tools/issue_close_test.go | 52 +++++++++++++++++++++++++++ internal/tools/issue_reopen.go | 56 +++++++++++++++++++++++++++++ internal/tools/issue_reopen_test.go | 40 +++++++++++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 internal/tools/issue_close.go create mode 100644 internal/tools/issue_close_test.go create mode 100644 internal/tools/issue_reopen.go create mode 100644 internal/tools/issue_reopen_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 70e11e5..21cd543 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -66,6 +66,8 @@ func main() { reg.Register(tools.NewRepoTree(giteaClient, ownerAllow)) reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow)) reg.Register(tools.NewIssueGet(giteaClient, ownerAllow)) + reg.Register(tools.NewIssueClose(giteaClient, ownerAllow)) + reg.Register(tools.NewIssueReopen(giteaClient, ownerAllow)) reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow)) reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow)) diff --git a/internal/gitea/issues.go b/internal/gitea/issues.go index 0b7b368..4aa8859 100644 --- a/internal/gitea/issues.go +++ b/internal/gitea/issues.go @@ -72,6 +72,28 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat return &iss, nil } +// SetIssueState flips an issue between "open" and "closed" via PATCH. +// Gitea uses the same endpoint for both transitions. +func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number) + payload, err := json.Marshal(map[string]string{"state": state}) + if err != nil { + return nil, err + } + body, status, err := c.PatchJSON(ctx, p, payload) + 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 +} + type IssueComment struct { ID int64 `json:"id"` Body string `json:"body"` diff --git a/internal/tools/issue_close.go b/internal/tools/issue_close.go new file mode 100644 index 0000000..eb7532d --- /dev/null +++ b/internal/tools/issue_close.go @@ -0,0 +1,56 @@ +package tools + +import ( + "context" + "encoding/json" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type IssueClose struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewIssueClose(c *gitea.Client, a *allowlist.Allowlist) *IssueClose { + return &IssueClose{c: c, a: a} +} + +func (t *IssueClose) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "issue_close", + Description: "Close an open issue. Reversible via issue_reopen.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "number":{"type":"integer","minimum":1} + }, + "required":["owner","name","number"] + }`), + } +} + +type issueCloseArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Number int `json:"number"` +} + +func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args issueCloseArgs + 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.SetIssueState(ctx, args.Owner, args.Name, args.Number, "closed") + if err != nil { + return nil, err + } + return textOK(iss) +} diff --git a/internal/tools/issue_close_test.go b/internal/tools/issue_close_test.go new file mode 100644 index 0000000..ebded3e --- /dev/null +++ b/internal/tools/issue_close_test.go @@ -0,0 +1,52 @@ +package tools_test + +import ( + "context" + "encoding/json" + "io" + "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 TestIssueCloseTool(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/issues/26", r.URL.Path) + b, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"state":"closed"}`, string(b)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"closed","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`)) + })) + defer srv.Close() + + tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"number":26`) + assert.Contains(t, string(out), `"state":"closed"`) +} + +func TestIssueCloseTool_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.NewIssueClose(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 TestIssueCloseAllowlistRejects(t *testing.T) { + tool := tools.NewIssueClose(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) +} diff --git a/internal/tools/issue_reopen.go b/internal/tools/issue_reopen.go new file mode 100644 index 0000000..c3a9f53 --- /dev/null +++ b/internal/tools/issue_reopen.go @@ -0,0 +1,56 @@ +package tools + +import ( + "context" + "encoding/json" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type IssueReopen struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewIssueReopen(c *gitea.Client, a *allowlist.Allowlist) *IssueReopen { + return &IssueReopen{c: c, a: a} +} + +func (t *IssueReopen) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "issue_reopen", + Description: "Reopen a closed issue. Reversible via issue_close.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "number":{"type":"integer","minimum":1} + }, + "required":["owner","name","number"] + }`), + } +} + +type issueReopenArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Number int `json:"number"` +} + +func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args issueReopenArgs + 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.SetIssueState(ctx, args.Owner, args.Name, args.Number, "open") + if err != nil { + return nil, err + } + return textOK(iss) +} diff --git a/internal/tools/issue_reopen_test.go b/internal/tools/issue_reopen_test.go new file mode 100644 index 0000000..bc223ea --- /dev/null +++ b/internal/tools/issue_reopen_test.go @@ -0,0 +1,40 @@ +package tools_test + +import ( + "context" + "encoding/json" + "io" + "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 TestIssueReopenTool(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/issues/26", r.URL.Path) + b, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"state":"open"}`, string(b)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`)) + })) + defer srv.Close() + + tool := tools.NewIssueReopen(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"number":26`) + assert.Contains(t, string(out), `"state":"open"`) +} + +func TestIssueReopenAllowlistRejects(t *testing.T) { + tool := tools.NewIssueReopen(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) +}