fix/v02-patch: pr_files_diff, template_name, repo_update #26

Merged
mathias merged 6 commits from fix/v02-patch into main 2026-05-16 22:03:29 +00:00
3 changed files with 153 additions and 48 deletions
Showing only changes of commit eeefc626ed - Show all commits

View File

@@ -216,6 +216,8 @@ type UpdateRepoArgs struct {
Private *bool `json:"private,omitempty"` Private *bool `json:"private,omitempty"`
Website *string `json:"website,omitempty"` Website *string `json:"website,omitempty"`
DefaultBranch *string `json:"default_branch,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) { 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 return &r, nil
} }

View File

@@ -22,17 +22,19 @@ func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "repo_update", Name: "repo_update",
Description: "Update repository metadata (description, visibility, default branch, website).", 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(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"name":{"type":"string"}, "name":{"type":"string"},
"archived":{"type":"boolean","description":"Mark repo as archived (read-only). Reversible."},
"description":{"type":"string"}, "description":{"type":"string"},
"private":{"type":"boolean"}, "private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."},
"website":{"type":"string"}, "website":{"type":"string","description":"Homepage URL"},
"default_branch":{"type":"string"}, "template":{"type":"boolean","description":"Toggle template-repo flag"}
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
}, },
"required":["owner","name"] "required":["owner","name"]
}`), }`),
@@ -42,11 +44,11 @@ func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
type repoUpdateArgs struct { type repoUpdateArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Archived *bool `json:"archived,omitempty"`
Private *bool `json:"private"` Description *string `json:"description,omitempty"`
Website *string `json:"website"` Private *bool `json:"private,omitempty"`
DefaultBranch *string `json:"default_branch"` Website *string `json:"website,omitempty"`
Confirm string `json:"confirm"` Template *bool `json:"template,omitempty"`
} }
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { 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 { if err := t.a.Check(args.Owner); err != nil {
return nil, err return nil, err
} }
// Making a repo public is a significant action — require explicit confirmation. if args.Name == "" {
if args.Private != nil && !*args.Private { return nil, fmt.Errorf("name required: %w", gitea.ErrValidation)
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.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)
} }
r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
updated, err := t.c.EditRepo(ctx, args.Owner, args.Name, gitea.EditRepoArgs{
Archived: args.Archived,
Description: args.Description, Description: args.Description,
Private: args.Private, Private: args.Private,
Website: args.Website, Website: args.Website,
DefaultBranch: args.DefaultBranch, Template: args.Template,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("edit repo: %w", err)
} }
return textOK(r) return textOK(updated)
} }

View File

@@ -3,6 +3,7 @@ package tools_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -14,43 +15,139 @@ import (
"github.com/stretchr/testify/require" "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) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method) require.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path) 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.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() defer srv.Close()
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) tool := newRepoUpdateTool(srv.URL)
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`)) result, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"mathias","name":"old-svc","archived":true}`,
))
require.NoError(t, err) 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) { // TestRepoUpdateMultipleFields: set description + template flag in one call.
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) func TestRepoUpdateMultipleFields(t *testing.T) {
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`)) 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=<repo name> 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) require.Error(t, err)
assert.Contains(t, err.Error(), "confirm") assert.Contains(t, err.Error(), "confirm")
} }
func TestRepoUpdateTool_MakePublicWithConfirm(t *testing.T) { // TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { func TestRepoUpdateAllowlistRejects(t *testing.T) {
w.Header().Set("Content-Type", "application/json") tool := tools.NewRepoUpdate(
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`)) 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() defer srv.Close()
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) tool := newRepoUpdateTool(srv.URL)
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`)) _, err := tool.Call(context.Background(), json.RawMessage(
require.NoError(t, err) `{"owner":"mathias","name":"some-repo","archived":true}`,
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"}`))
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrUpstream)
} }