feat: add issue_edit tool (#34)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Failing after 0s
CD / Deploy via GitOps (push) Has been skipped

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
This commit is contained in:
2026-06-02 16:11:33 +02:00
parent 668e8fa28d
commit 8a751741a4
5 changed files with 303 additions and 10 deletions
+80
View File
@@ -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,
})
}
+91
View File
@@ -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)
}