feat(repo_update): tool for archiving + metadata patches
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -22,17 +22,19 @@ 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).",
|
||||
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"]
|
||||
}`),
|
||||
@@ -42,11 +44,11 @@ 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"`
|
||||
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)
|
||||
}
|
||||
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,
|
||||
Private: args.Private,
|
||||
Website: args.Website,
|
||||
DefaultBranch: args.DefaultBranch,
|
||||
Template: args.Template,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("edit repo: %w", err)
|
||||
}
|
||||
return textOK(r)
|
||||
return textOK(updated)
|
||||
}
|
||||
|
||||
@@ -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=<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)
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user