From 5dac4856bd972327358f06043fd5990978e030b6 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Wed, 6 May 2026 22:51:21 +0200 Subject: [PATCH] feat(tools): file_delete --- internal/gitea/files.go | 26 ++++++++++ internal/gitea/files_test.go | 32 ++++++++++++ internal/tools/file_delete.go | 78 ++++++++++++++++++++++++++++++ internal/tools/file_delete_test.go | 52 ++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 internal/tools/file_delete.go create mode 100644 internal/tools/file_delete_test.go diff --git a/internal/gitea/files.go b/internal/gitea/files.go index 22df92e..2d2e4ad 100644 --- a/internal/gitea/files.go +++ b/internal/gitea/files.go @@ -181,6 +181,32 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path, ref string return entries, nil } +type DeleteFileArgs struct { + Branch string `json:"branch"` + Message string `json:"message"` + Sha string `json:"sha"` +} + +func (c *Client) DeleteFile(ctx context.Context, owner, repo, path string, args DeleteFileArgs) (*FileWriteResult, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path) + payload, err := json.Marshal(args) + if err != nil { + return nil, err + } + body, status, err := c.DeleteJSONBody(ctx, p, payload) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var out FileWriteResult + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return &out, nil +} + // UpsertFile creates a file when args.Sha is empty (POST) or updates an existing // file when args.Sha is set (PUT). Gitea routes both operations by HTTP method on // the same /contents/{path} URL, and rejects PUT without a sha. diff --git a/internal/gitea/files_test.go b/internal/gitea/files_test.go index f541bd1..ef2bcb8 100644 --- a/internal/gitea/files_test.go +++ b/internal/gitea/files_test.go @@ -233,3 +233,35 @@ func TestListContentsOnFileReturnsError(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrValidation) } + +func TestDeleteFile(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/contents/src/old.go", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "content":null, + "commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"} + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + result, err := c.DeleteFile(context.Background(), "o", "r", "src/old.go", gitea.DeleteFileArgs{ + Branch: "main", + Message: "remove old.go", + Sha: "blobsha", + }) + require.NoError(t, err) + assert.Equal(t, "cmt1", result.Commit.Sha) + + var payload map[string]string + require.NoError(t, json.Unmarshal(captured, &payload)) + assert.Equal(t, "main", payload["branch"]) + assert.Equal(t, "remove old.go", payload["message"]) + assert.Equal(t, "blobsha", payload["sha"]) +} diff --git a/internal/tools/file_delete.go b/internal/tools/file_delete.go new file mode 100644 index 0000000..a06e6cb --- /dev/null +++ b/internal/tools/file_delete.go @@ -0,0 +1,78 @@ +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 FileDelete struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewFileDelete(c *gitea.Client, a *allowlist.Allowlist) *FileDelete { + return &FileDelete{c: c, a: a} +} + +func (t *FileDelete) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "file_delete", + Description: "Delete a file from a repository branch. sha is the current blob SHA (from file_read).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "path":{"type":"string"}, + "branch":{"type":"string"}, + "message":{"type":"string"}, + "sha":{"type":"string"} + }, + "required":["owner","name","path","branch","message","sha"] + }`), + } +} + +type fileDeleteArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Message string `json:"message"` + Sha string `json:"sha"` +} + +func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args fileDeleteArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Sha == "" { + return nil, fmt.Errorf("sha is required: %w", gitea.ErrValidation) + } + if args.Message == "" { + return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation) + } + + result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{ + Branch: args.Branch, + Message: args.Message, + Sha: args.Sha, + }) + if err != nil { + return nil, err + } + + return textOK(map[string]any{ + "commit_sha": result.Commit.Sha, + "html_url": result.Commit.HTMLURL, + }) +} diff --git a/internal/tools/file_delete_test.go b/internal/tools/file_delete_test.go new file mode 100644 index 0000000..f665db7 --- /dev/null +++ b/internal/tools/file_delete_test.go @@ -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 TestFileDeleteSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`)) + })) + defer srv.Close() + + tool := tools.NewFileDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"owner","name":"repo","path":"src/old.go", + "branch":"main","message":"remove old.go","sha":"blobsha" + }`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, "cmt1", result["commit_sha"]) +} + +func TestFileDeleteRequiresSha(t *testing.T) { + tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"owner","name":"repo","path":"f.go","branch":"main","message":"rm" + }`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +func TestFileDeleteAllowlistRejects(t *testing.T) { + tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"evil","name":"repo","path":"f.go","branch":"main","message":"rm","sha":"abc" + }`)) + require.Error(t, err) +}