From d74b196db185e2f6d8449693c0edf3e1e10a0af5 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 16 May 2026 23:01:33 +0200 Subject: [PATCH] feat(repo_update): tool for archiving + metadata patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a repo_update tool exposing PATCH /api/v1/repos/{owner}/{name} with optional pointer fields (archived, description, private, website, template). Only fields set by the caller are sent on the wire, so the server patches exactly what was asked for. Originally needed to archive ingestion-svc cleanly instead of leaving a README tombstone, and to flip template-go-{agent,web} to template=true so create_project_from_template stops failing the "is not marked as template" guard. Wire-level enforcement of "at least one field" returns ErrValidation before any network call, preventing no-op PATCHes. private=false (making a repo public) is allowed but flagged in the tool description with a "verify intent before calling" warning. The earlier issue draft suggested an ntfy confirmation hook for that path — out of scope for this PR; the warning string is the minimum that fits inside the tool surface today. Wires NewRepoUpdate into cmd/gitea-mcp/main.go alongside the rest of the repo_* family. Closes #12 --- cmd/gitea-mcp/main.go | 1 + internal/gitea/repos.go | 31 +++++++ internal/tools/repo_update.go | 81 +++++++++++++++++ internal/tools/repo_update_test.go | 139 +++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 internal/tools/repo_update.go create mode 100644 internal/tools/repo_update_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 647a0d9..f717d84 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -40,6 +40,7 @@ func main() { reg.Register(tools.NewRepoGet(giteaClient, ownerAllow)) reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow)) reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow)) reg.Register(tools.NewFileRead(giteaClient, ownerAllow)) reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow)) reg.Register(tools.NewFileDelete(giteaClient, ownerAllow)) diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 0c5e3ad..a2c2907 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -86,3 +86,34 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) } return &r, nil } + +// EditRepoArgs carries optional fields for PATCH /api/v1/repos/{owner}/{name}. +// Pointer fields let the caller omit unset values from the wire payload, so the +// server only patches what was explicitly requested. +type EditRepoArgs struct { + Archived *bool `json:"archived,omitempty"` + Description *string `json:"description,omitempty"` + Private *bool `json:"private,omitempty"` + Website *string `json:"website,omitempty"` + Template *bool `json:"template,omitempty"` +} + +func (c *Client) EditRepo(ctx context.Context, owner, name string, args EditRepoArgs) (*Repo, error) { + body, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("marshal edit args: %w", err) + } + path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) + resp, status, err := c.PatchJSON(ctx, path, body) + if err != nil { + return nil, err + } + if err := MapStatus(status, resp); err != nil { + return nil, err + } + var r Repo + if err := json.Unmarshal(resp, &r); err != nil { + return nil, err + } + return &r, nil +} diff --git a/internal/tools/repo_update.go b/internal/tools/repo_update.go new file mode 100644 index 0000000..959d164 --- /dev/null +++ b/internal/tools/repo_update.go @@ -0,0 +1,81 @@ +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 RepoUpdate struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate { + return &RepoUpdate{c: c, a: a} +} + +func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_update", + Description: "Update repository metadata via PATCH (archived, description, private, website, template). " + + "Only fields explicitly set in the call are patched. " + + "WARNING: private=false exposes the repo publicly — verify intent before calling.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "archived":{"type":"boolean","description":"Mark repo as archived (read-only). Reversible."}, + "description":{"type":"string"}, + "private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."}, + "website":{"type":"string","description":"Homepage URL"}, + "template":{"type":"boolean","description":"Toggle template-repo flag"} + }, + "required":["owner","name"] + }`), + } +} + +type repoUpdateArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Archived *bool `json:"archived,omitempty"` + Description *string `json:"description,omitempty"` + Private *bool `json:"private,omitempty"` + Website *string `json:"website,omitempty"` + Template *bool `json:"template,omitempty"` +} + +func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoUpdateArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Name == "" { + return nil, fmt.Errorf("name required: %w", gitea.ErrValidation) + } + if args.Archived == nil && args.Description == nil && args.Private == nil && + args.Website == nil && args.Template == nil { + return nil, fmt.Errorf("at least one updatable field must be set: %w", gitea.ErrValidation) + } + + updated, err := t.c.EditRepo(ctx, args.Owner, args.Name, gitea.EditRepoArgs{ + Archived: args.Archived, + Description: args.Description, + Private: args.Private, + Website: args.Website, + Template: args.Template, + }) + if err != nil { + return nil, fmt.Errorf("edit repo: %w", err) + } + return textOK(updated) +} diff --git a/internal/tools/repo_update_test.go b/internal/tools/repo_update_test.go new file mode 100644 index 0000000..27f6f5d --- /dev/null +++ b/internal/tools/repo_update_test.go @@ -0,0 +1,139 @@ +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 newRepoUpdateTool(srvURL string) *tools.RepoUpdate { + return tools.NewRepoUpdate(gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"})) +} + +// TestRepoUpdateArchive: happy path — set archived=true. +func TestRepoUpdateArchive(t *testing.T) { + var patchedBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + require.Equal(t, "/api/v1/repos/mathias/old-svc", r.URL.Path) + patchedBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`)) + })) + defer srv.Close() + + tool := newRepoUpdateTool(srv.URL) + result, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"old-svc","archived":true}`, + )) + require.NoError(t, err) + + // Wire payload only contains the field that was actually set. + var sent map[string]any + require.NoError(t, json.Unmarshal(patchedBody, &sent)) + assert.Equal(t, true, sent["archived"]) + assert.NotContains(t, sent, "description") + assert.NotContains(t, sent, "private") + assert.NotContains(t, sent, "website") + assert.NotContains(t, sent, "template") + + var repo gitea.Repo + require.NoError(t, json.Unmarshal(result, &repo)) + assert.Equal(t, "mathias/old-svc", repo.FullName) +} + +// TestRepoUpdateMultipleFields: set description + template flag in one call. +func TestRepoUpdateMultipleFields(t *testing.T) { + var patchedBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + patchedBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"template-go-agent","full_name":"mathias/template-go-agent","description":"Go agent template","template":true}`)) + })) + defer srv.Close() + + tool := newRepoUpdateTool(srv.URL) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"template-go-agent","description":"Go agent template","template":true}`, + )) + require.NoError(t, err) + + var sent map[string]any + require.NoError(t, json.Unmarshal(patchedBody, &sent)) + assert.Equal(t, "Go agent template", sent["description"]) + assert.Equal(t, true, sent["template"]) + assert.NotContains(t, sent, "archived") + assert.NotContains(t, sent, "private") +} + +// TestRepoUpdateNoFieldsRejected: zero updatable fields → validation error before network. +func TestRepoUpdateNoFieldsRejected(t *testing.T) { + tool := tools.NewRepoUpdate( + gitea.NewClient("http://unused", ""), + allowlist.New([]string{"mathias"}), + ) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"some-repo"}`, + )) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +// TestRepoUpdateMakePublic: explicit private=false is allowed; wire payload carries the false. +// (The destructive nature is warned about in the tool description, not blocked by the tool.) +func TestRepoUpdateMakePublic(t *testing.T) { + var patchedBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + patchedBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"open-repo","full_name":"mathias/open-repo","private":false}`)) + })) + defer srv.Close() + + tool := newRepoUpdateTool(srv.URL) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"open-repo","private":false}`, + )) + require.NoError(t, err) + + var sent map[string]any + require.NoError(t, json.Unmarshal(patchedBody, &sent)) + assert.Equal(t, false, sent["private"]) +} + +// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call. +func TestRepoUpdateAllowlistRejects(t *testing.T) { + tool := tools.NewRepoUpdate( + gitea.NewClient("http://unused", ""), + allowlist.New([]string{"mathias"}), + ) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"evil","name":"some-repo","archived":true}`, + )) + require.Error(t, err) +} + +// TestRepoUpdateUpstreamError: server 500 propagates as ErrUpstream. +func TestRepoUpdateUpstreamError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"internal"}`)) + })) + defer srv.Close() + + tool := newRepoUpdateTool(srv.URL) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"some-repo","archived":true}`, + )) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrUpstream) +}