diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 46b7d9d..c899e2b 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -216,6 +216,8 @@ type UpdateRepoArgs struct { Private *bool `json:"private,omitempty"` Website *string `json:"website,omitempty"` DefaultBranch *string `json:"default_branch,omitempty"` + Archived *bool `json:"archived,omitempty"` + Template *bool `json:"template,omitempty"` } func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) { @@ -253,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) } return &r, nil } + diff --git a/internal/tools/repo_update.go b/internal/tools/repo_update.go index 74d22e3..959d164 100644 --- a/internal/tools/repo_update.go +++ b/internal/tools/repo_update.go @@ -21,18 +21,20 @@ func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate { func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ - Name: "repo_update", - Description: "Update repository metadata (description, visibility, default branch, website).", + 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"}, - "website":{"type":"string"}, - "default_branch":{"type":"string"}, - "confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."} + "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"] }`), @@ -40,13 +42,13 @@ func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { } type repoUpdateArgs struct { - Owner string `json:"owner"` - Name string `json:"name"` - Description *string `json:"description"` - Private *bool `json:"private"` - Website *string `json:"website"` - DefaultBranch *string `json:"default_branch"` - Confirm string `json:"confirm"` + 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) { @@ -57,20 +59,23 @@ func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes if err := t.a.Check(args.Owner); err != nil { return nil, err } - // Making a repo public is a significant action — require explicit confirmation. - if args.Private != nil && !*args.Private { - if args.Confirm != args.Name { - return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name) - } + if args.Name == "" { + return nil, fmt.Errorf("name required: %w", gitea.ErrValidation) } - r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{ - Description: args.Description, - Private: args.Private, - Website: args.Website, - DefaultBranch: args.DefaultBranch, + 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, err + return nil, fmt.Errorf("edit repo: %w", err) } - return textOK(r) + return textOK(updated) } diff --git a/internal/tools/repo_update_test.go b/internal/tools/repo_update_test.go index ff930cc..889f92e 100644 --- a/internal/tools/repo_update_test.go +++ b/internal/tools/repo_update_test.go @@ -3,6 +3,7 @@ package tools_test import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -14,43 +15,139 @@ import ( "github.com/stretchr/testify/require" ) -func TestRepoUpdateTool(t *testing.T) { +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) { - assert.Equal(t, http.MethodPatch, r.Method) - assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path) + 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":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + _, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`)) })) defer srv.Close() - tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) - out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`)) + tool := newRepoUpdateTool(srv.URL) + result, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"old-svc","archived":true}`, + )) require.NoError(t, err) - assert.Contains(t, string(out), `"description":"updated"`) + + // 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) } -func TestRepoUpdateTool_MakePublicRequiresConfirm(t *testing.T) { - tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) - _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`)) +// 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: private=false requires confirm= as a safety +// gate (kept from main #21 during the v02-patch merge). With confirm matching, the +// patch goes through. +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,"confirm":"open-repo"}`, + )) + require.NoError(t, err) + + var sent map[string]any + require.NoError(t, json.Unmarshal(patchedBody, &sent)) + assert.Equal(t, false, sent["private"]) +} + +// TestRepoUpdateMakePublicWithoutConfirm: confirm gate blocks private=false without confirmation. +func TestRepoUpdateMakePublicWithoutConfirm(t *testing.T) { + tool := tools.NewRepoUpdate( + gitea.NewClient("http://unused", ""), + allowlist.New([]string{"mathias"}), + ) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"open-repo","private":false}`, + )) require.Error(t, err) assert.Contains(t, err.Error(), "confirm") } -func TestRepoUpdateTool_MakePublicWithConfirm(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`)) +// 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 := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) - out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`)) - require.NoError(t, err) - assert.Contains(t, string(out), `"full_name":"mathias/infra"`) -} - -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":"x"}`)) + 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) }