From 8a751741a4d1fafd025073007cbca9aa7bc0124e Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 16:11:33 +0200 Subject: [PATCH] feat: add issue_edit tool (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds issue_edit, mapping to Gitea's PATCH /repos/{owner}/{repo}/issues/{index}, so an existing issue's title and/or body can be edited through the MCP surface. Previously the only post-create mutation was issue_comment, which buries backlinks in the thread instead of the canonical body. Partial patch via pointer fields (omitempty): omitted fields are left untouched, an explicit empty string clears a field. Body is sent verbatim — no identity footer — so repeated edits are idempotent, matching the acceptance criteria. Registered alongside the other issue tools for tool_search discovery. Closes #34 --- cmd/gitea-mcp/main.go | 1 + internal/gitea/issues.go | 51 +++++++++++++---- internal/gitea/issues_test.go | 90 ++++++++++++++++++++++++++++++ internal/tools/issue_edit.go | 80 +++++++++++++++++++++++++++ internal/tools/issue_edit_test.go | 91 +++++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 internal/tools/issue_edit.go create mode 100644 internal/tools/issue_edit_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index ccaa3ef..fb42eb5 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -59,6 +59,7 @@ func main() { reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow)) reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow)) reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow)) + reg.Register(tools.NewIssueEdit(giteaClient, ownerAllow)) reg.Register(tools.NewIssueComment(giteaClient, ownerAllow)) reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web")) reg.Register(tools.NewTagCreate(giteaClient, ownerAllow)) diff --git a/internal/gitea/issues.go b/internal/gitea/issues.go index 082c80a..fb58e54 100644 --- a/internal/gitea/issues.go +++ b/internal/gitea/issues.go @@ -9,16 +9,16 @@ import ( ) type Issue struct { - Number int `json:"number"` - Title string `json:"title"` - 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"` + Number int `json:"number"` + Title string `json:"title"` + 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 { @@ -140,6 +140,37 @@ func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number i return &iss, nil } +// EditIssueArgs uses pointers so omitempty distinguishes "not set" (nil, +// left untouched) from an explicit empty string (clears the field). Maps to +// Gitea's PATCH /repos/{owner}/{repo}/issues/{index}. +type EditIssueArgs struct { + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` +} + +// EditIssue patches an issue's title and/or body. Only fields set in args are +// sent, so omitted fields are left as-is server-side. Body is sent verbatim — +// no identity footer — so repeated edits are idempotent. +func (c *Client) EditIssue(ctx context.Context, owner, repo string, number int, args EditIssueArgs) (*Issue, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number) + payload, err := json.Marshal(args) + 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/gitea/issues_test.go b/internal/gitea/issues_test.go index ef4efc6..1bb7a38 100644 --- a/internal/gitea/issues_test.go +++ b/internal/gitea/issues_test.go @@ -76,6 +76,96 @@ func TestGetIssue_NotFound(t *testing.T) { assert.ErrorIs(t, err, gitea.ErrNotFound) } +func TestEditIssue(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path) + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":42,"title":"new title","body":"new body","state":"open","html_url":"http://example.com/issues/42"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + title, body := "new title", "new body" + iss, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title, Body: &body}) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "new title", payload["title"]) + assert.Equal(t, "new body", payload["body"]) + + assert.Equal(t, 42, iss.Number) + assert.Equal(t, "new title", iss.Title) +} + +// EditIssue must send only the fields explicitly provided — an omitted field +// (nil pointer) is left untouched server-side. +func TestEditIssue_PartialPatchOmitsUnsetFields(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":42,"title":"only title","state":"open"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + title := "only title" + _, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title}) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "only title", payload["title"]) + _, hasBody := payload["body"] + assert.False(t, hasBody, "body must be omitted when not set") +} + +// An explicit empty-string body clears the field — pointer-to-"" is sent, not omitted. +func TestEditIssue_EmptyBodyIsSent(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":42,"body":"","state":"open"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + body := "" + _, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Body: &body}) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + val, hasBody := payload["body"] + assert.True(t, hasBody, "explicit empty body must be sent so it can clear the field") + assert.Equal(t, "", val) +} + +func TestEditIssue_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") + title := "x" + _, err := c.EditIssue(context.Background(), "o", "r", 999, gitea.EditIssueArgs{Title: &title}) + 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) { diff --git a/internal/tools/issue_edit.go b/internal/tools/issue_edit.go new file mode 100644 index 0000000..6d9c3a7 --- /dev/null +++ b/internal/tools/issue_edit.go @@ -0,0 +1,80 @@ +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 IssueEdit struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewIssueEdit(c *gitea.Client, a *allowlist.Allowlist) *IssueEdit { + return &IssueEdit{c: c, a: a} +} + +func (t *IssueEdit) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "issue_edit", + Description: "Edit an existing issue's title and/or body. Only fields explicitly set are patched; " + + "omitted fields are left untouched. Body is replaced verbatim (no identity footer) so edits are idempotent. " + + "WARNING: body is a full replacement — to amend rather than clobber, read-modify-write " + + "(fetch with issue_get, edit the text, send it back).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "number":{"type":"integer","minimum":1}, + "title":{"type":"string","description":"New title. Omit to leave unchanged."}, + "body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."} + }, + "required":["owner","name","number"] + }`), + } +} + +type issueEditArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Number int `json:"number"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` +} + +func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args issueEditArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Number < 1 { + return nil, fmt.Errorf("number is required: %w", gitea.ErrValidation) + } + if args.Title == nil && args.Body == nil { + return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation) + } + + iss, err := t.c.EditIssue(ctx, args.Owner, args.Name, args.Number, gitea.EditIssueArgs{ + Title: args.Title, + Body: args.Body, + }) + if err != nil { + return nil, err + } + + return textOK(map[string]any{ + "number": iss.Number, + "title": iss.Title, + "html_url": iss.HTMLURL, + "state": iss.State, + }) +} diff --git a/internal/tools/issue_edit_test.go b/internal/tools/issue_edit_test.go new file mode 100644 index 0000000..c9dde87 --- /dev/null +++ b/internal/tools/issue_edit_test.go @@ -0,0 +1,91 @@ +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 TestIssueEditTool(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path) + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":26,"title":"new","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`)) + })) + defer srv.Close() + + tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"title":"new","body":"updated body"}`)) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "new", payload["title"]) + assert.Equal(t, "updated body", payload["body"]) + + assert.Contains(t, string(out), `"number":26`) +} + +// Body must be sent verbatim — no identity footer appended (keeps edits idempotent). +func TestIssueEditTool_BodyVerbatim(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":26,"state":"open"}`)) + })) + defer srv.Close() + + tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"body":"exact text"}`)) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "exact text", payload["body"], "body must be unchanged — no footer") + _, hasTitle := payload["title"] + assert.False(t, hasTitle, "title must be omitted when not provided") +} + +func TestIssueEditTool_RequiresAField(t *testing.T) { + tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +func TestIssueEditTool_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.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + title := "x" + body, _ := json.Marshal(map[string]any{"owner": "mathias", "name": "infra", "number": 999, "title": title}) + _, err := tool.Call(context.Background(), body) + require.Error(t, err) +} + +func TestIssueEditAllowlistRejects(t *testing.T) { + tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1,"title":"y"}`)) + require.Error(t, err) +}