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) +}