From e2da4955810a71059cbb5095862abb5b806fabb9 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 15 May 2026 10:14:18 +0200 Subject: [PATCH] feat(tools): add repo_create, repo_update, repo_mirror_push (#12, #13, #16) repo_create: POST /user/repos or /orgs/{org}/repos, is_org flag routes repo_update: PATCH /repos/{owner}/{repo}, confirm required when private=false repo_mirror_push: add/list/delete push mirrors, password never returned --- cmd/gitea-mcp/main.go | 3 + internal/gitea/mirrors.go | 71 ++++++++++++++ internal/gitea/mirrors_test.go | 64 +++++++++++++ internal/gitea/repos.go | 64 +++++++++++++ internal/gitea/repos_test.go | 57 ++++++++++++ internal/tools/repo_create.go | 74 +++++++++++++++ internal/tools/repo_create_test.go | 53 +++++++++++ internal/tools/repo_mirror_push.go | 117 ++++++++++++++++++++++++ internal/tools/repo_mirror_push_test.go | 80 ++++++++++++++++ internal/tools/repo_update.go | 76 +++++++++++++++ internal/tools/repo_update_test.go | 56 ++++++++++++ 11 files changed, 715 insertions(+) create mode 100644 internal/gitea/mirrors.go create mode 100644 internal/gitea/mirrors_test.go create mode 100644 internal/tools/repo_create.go create mode 100644 internal/tools/repo_create_test.go create mode 100644 internal/tools/repo_mirror_push.go create mode 100644 internal/tools/repo_mirror_push_test.go 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..95e0b35 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -60,6 +60,9 @@ func main() { reg.Register(tools.NewIssueComment(giteaClient, ownerAllow)) reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web")) reg.Register(tools.NewTagCreate(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow)) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/mirrors.go b/internal/gitea/mirrors.go new file mode 100644 index 0000000..4848e95 --- /dev/null +++ b/internal/gitea/mirrors.go @@ -0,0 +1,71 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" +) + +type PushMirror struct { + ID int `json:"id"` + RemoteName string `json:"remote_name"` + RemoteAddress string `json:"remote_address"` + Interval string `json:"interval"` + SyncOnCommit bool `json:"sync_on_commit"` +} + +type AddPushMirrorArgs struct { + RemoteAddress string `json:"remote_address"` + RemoteUsername string `json:"remote_username,omitempty"` + RemotePassword string `json:"remote_password,omitempty"` + Interval string `json:"interval,omitempty"` + SyncOnCommit bool `json:"sync_on_commit,omitempty"` +} + +func (c *Client) AddPushMirror(ctx context.Context, owner, repo string, args AddPushMirrorArgs) (*PushMirror, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo) + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + resp, status, err := c.PostJSON(ctx, path, body) + if err != nil { + return nil, err + } + if err := MapStatus(status, resp); err != nil { + return nil, err + } + var m PushMirror + if err := json.Unmarshal(resp, &m); err != nil { + return nil, err + } + return &m, nil +} + +func (c *Client) ListPushMirrors(ctx context.Context, owner, repo string) ([]PushMirror, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo) + resp, status, err := c.GetJSON(ctx, path) + if err != nil { + return nil, err + } + if err := MapStatus(status, resp); err != nil { + return nil, err + } + var mirrors []PushMirror + if err := json.Unmarshal(resp, &mirrors); err != nil { + return nil, err + } + return mirrors, nil +} + +func (c *Client) DeletePushMirror(ctx context.Context, owner, repo, mirrorName string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors/%s", owner, repo, mirrorName) + resp, status, err := c.DeleteJSON(ctx, path) + if err != nil { + return err + } + if status == 204 { + return nil + } + return MapStatus(status, resp) +} diff --git a/internal/gitea/mirrors_test.go b/internal/gitea/mirrors_test.go new file mode 100644 index 0000000..b173ac4 --- /dev/null +++ b/internal/gitea/mirrors_test.go @@ -0,0 +1,64 @@ +package gitea_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddPushMirror(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{ + RemoteAddress: "https://github.com/mathias/infra.git", + RemoteUsername: "mathias", + RemotePassword: "secret", + Interval: "8h0m0s", + SyncOnCommit: true, + }) + require.NoError(t, err) + assert.Equal(t, "mirror-github", m.RemoteName) + assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress) +} + +func TestListPushMirrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra") + require.NoError(t, err) + require.Len(t, mirrors, 1) + assert.Equal(t, "mirror-github", mirrors[0].RemoteName) +} + +func TestDeletePushMirror(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github") + require.NoError(t, err) +} diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 0c5e3ad..77f6043 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -71,6 +71,70 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i return env.Data, nil } +type CreateRepoArgs struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Private bool `json:"private,omitempty"` + AutoInit bool `json:"auto_init,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + // Org, when non-empty, creates the repo under the named organisation. + // Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos. + Org string `json:"-"` +} + +func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) { + var path string + if args.Org != "" { + path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org) + } else { + path = "/api/v1/user/repos" + } + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + resp, status, err := c.PostJSON(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 +} + +// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero. +type UpdateRepoArgs struct { + Description *string `json:"description,omitempty"` + Private *bool `json:"private,omitempty"` + Website *string `json:"website,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` +} + +func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + 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 +} + func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) { path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) body, status, err := c.GetJSON(ctx, path) diff --git a/internal/gitea/repos_test.go b/internal/gitea/repos_test.go index 741bd57..8a11f67 100644 --- a/internal/gitea/repos_test.go +++ b/internal/gitea/repos_test.go @@ -47,6 +47,63 @@ func TestListRepos(t *testing.T) { assert.Equal(t, "main", repos[0].DefaultBranch) } +func TestCreateRepo_User(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/user/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{ + Name: "infra", + Private: true, + }) + require.NoError(t, err) + assert.Equal(t, "mathias/infra", r.FullName) + assert.Equal(t, "main", r.DefaultBranch) +} + +func TestCreateRepo_Org(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{ + Name: "infra", + Org: "hyperguild", + }) + require.NoError(t, err) + assert.Equal(t, "hyperguild/infra", r.FullName) +} + +func TestUpdateRepo(t *testing.T) { + 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) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + })) + defer srv.Close() + + desc := "updated" + c := gitea.NewClient(srv.URL, "tok") + r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{ + Description: &desc, + }) + require.NoError(t, err) + assert.Equal(t, "updated", r.Description) +} + func TestDefaultBranchCachesAcrossCalls(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/tools/repo_create.go b/internal/tools/repo_create.go new file mode 100644 index 0000000..6563604 --- /dev/null +++ b/internal/tools/repo_create.go @@ -0,0 +1,74 @@ +package tools + +import ( + "context" + "encoding/json" + + "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 RepoCreate struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoCreate(c *gitea.Client, a *allowlist.Allowlist) *RepoCreate { + return &RepoCreate{c: c, a: a} +} + +func (t *RepoCreate) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_create", + Description: "Create a repository for the authenticated user or an organisation.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string","description":"Username or org name (used for allowlist check)."}, + "name":{"type":"string","description":"Repository name."}, + "description":{"type":"string"}, + "private":{"type":"boolean","description":"Create as private. Default false."}, + "auto_init":{"type":"boolean","description":"Initialise with README."}, + "default_branch":{"type":"string","description":"Default branch name. Default 'main'."}, + "is_org":{"type":"boolean","description":"When true, create under the organisation named in 'owner'."} + }, + "required":["owner","name"] + }`), + } +} + +type repoCreateArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` + DefaultBranch string `json:"default_branch"` + IsOrg bool `json:"is_org"` +} + +func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoCreateArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + createArgs := gitea.CreateRepoArgs{ + Name: args.Name, + Description: args.Description, + Private: args.Private, + AutoInit: args.AutoInit, + DefaultBranch: args.DefaultBranch, + } + if args.IsOrg { + createArgs.Org = args.Owner + } + r, err := t.c.CreateRepo(ctx, createArgs) + if err != nil { + return nil, err + } + return textOK(r) +} diff --git a/internal/tools/repo_create_test.go b/internal/tools/repo_create_test.go new file mode 100644 index 0000000..fc64179 --- /dev/null +++ b/internal/tools/repo_create_test.go @@ -0,0 +1,53 @@ +package tools_test + +import ( + "context" + "encoding/json" + "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 TestRepoCreateTool_User(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/user/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + })) + defer srv.Close() + + tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":true}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"full_name":"mathias/infra"`) + assert.Contains(t, string(out), `"clone_url"`) +} + +func TestRepoCreateTool_Org(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`)) + })) + defer srv.Close() + + tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"hyperguild"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"hyperguild","name":"infra","is_org":true}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"full_name":"hyperguild/infra"`) +} + +func TestRepoCreateAllowlistRejects(t *testing.T) { + tool := tools.NewRepoCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`)) + require.Error(t, err) +} diff --git a/internal/tools/repo_mirror_push.go b/internal/tools/repo_mirror_push.go new file mode 100644 index 0000000..783c863 --- /dev/null +++ b/internal/tools/repo_mirror_push.go @@ -0,0 +1,117 @@ +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 RepoMirrorPush struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush { + return &RepoMirrorPush{c: c, a: a} +} + +func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_mirror_push", + Description: "Manage push mirrors for a repository: add, list, or delete.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "action":{"type":"string","enum":["add","list","delete"]}, + "remote_address":{"type":"string","description":"Mirror target URL (required for add)."}, + "remote_username":{"type":"string"}, + "remote_password":{"type":"string","description":"Never logged or returned."}, + "interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."}, + "sync_on_commit":{"type":"boolean"}, + "mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."} + }, + "required":["owner","name","action"] + }`), + } +} + +type repoMirrorPushArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Action string `json:"action"` + RemoteAddress string `json:"remote_address"` + RemoteUsername string `json:"remote_username"` + RemotePassword string `json:"remote_password"` + Interval string `json:"interval"` + SyncOnCommit bool `json:"sync_on_commit"` + MirrorName string `json:"mirror_name"` +} + +// safeMirror omits remote_password so it is never returned to the caller. +type safeMirror struct { + ID int `json:"id"` + RemoteName string `json:"remote_name"` + RemoteAddress string `json:"remote_address"` + Interval string `json:"interval"` + SyncOnCommit bool `json:"sync_on_commit"` +} + +func toSafeMirror(m *gitea.PushMirror) safeMirror { + return safeMirror{ + ID: m.ID, + RemoteName: m.RemoteName, + RemoteAddress: m.RemoteAddress, + Interval: m.Interval, + SyncOnCommit: m.SyncOnCommit, + } +} + +func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoMirrorPushArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + switch args.Action { + case "add": + m, err := t.c.AddPushMirror(ctx, args.Owner, args.Name, gitea.AddPushMirrorArgs{ + RemoteAddress: args.RemoteAddress, + RemoteUsername: args.RemoteUsername, + RemotePassword: args.RemotePassword, + Interval: args.Interval, + SyncOnCommit: args.SyncOnCommit, + }) + if err != nil { + return nil, err + } + return textOK(toSafeMirror(m)) + case "list": + mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Name) + if err != nil { + return nil, err + } + safe := make([]safeMirror, len(mirrors)) + for i := range mirrors { + safe[i] = toSafeMirror(&mirrors[i]) + } + return textOK(safe) + case "delete": + if args.MirrorName == "" { + return nil, fmt.Errorf("mirror_name is required for action=delete") + } + if err := t.c.DeletePushMirror(ctx, args.Owner, args.Name, args.MirrorName); err != nil { + return nil, err + } + return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName}) + default: + return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action) + } +} diff --git a/internal/tools/repo_mirror_push_test.go b/internal/tools/repo_mirror_push_test.go new file mode 100644 index 0000000..be90233 --- /dev/null +++ b/internal/tools/repo_mirror_push_test.go @@ -0,0 +1,80 @@ +package tools_test + +import ( + "context" + "encoding/json" + "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 TestRepoMirrorPushTool_Add(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`)) + })) + defer srv.Close() + + tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{ + "owner":"mathias","name":"infra","action":"add", + "remote_address":"https://github.com/mathias/infra.git", + "remote_username":"mathias","remote_password":"secret", + "interval":"8h0m0s","sync_on_commit":true + }`)) + require.NoError(t, err) + // password must never appear in output + assert.NotContains(t, string(out), "secret") + assert.Contains(t, string(out), `"remote_name":"mirror-github"`) +} + +func TestRepoMirrorPushTool_List(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`)) + })) + defer srv.Close() + + tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"list"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"remote_name":"mirror-github"`) +} + +func TestRepoMirrorPushTool_Delete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete","mirror_name":"mirror-github"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), "deleted") +} + +func TestRepoMirrorPushTool_DeleteRequiresMirrorName(t *testing.T) { + tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "mirror_name") +} + +func TestRepoMirrorPushTool_AllowlistRejects(t *testing.T) { + tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","action":"list"}`)) + require.Error(t, err) +} diff --git a/internal/tools/repo_update.go b/internal/tools/repo_update.go new file mode 100644 index 0000000..74d22e3 --- /dev/null +++ b/internal/tools/repo_update.go @@ -0,0 +1,76 @@ +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 (description, visibility, default branch, website).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "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."} + }, + "required":["owner","name"] + }`), + } +} + +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"` +} + +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 + } + // 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) + } + } + 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 err != nil { + return nil, err + } + return textOK(r) +} diff --git a/internal/tools/repo_update_test.go b/internal/tools/repo_update_test.go new file mode 100644 index 0000000..ff930cc --- /dev/null +++ b/internal/tools/repo_update_test.go @@ -0,0 +1,56 @@ +package tools_test + +import ( + "context" + "encoding/json" + "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 TestRepoUpdateTool(t *testing.T) { + 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) + 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"}`)) + })) + 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"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"description":"updated"`) +} + +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}`)) + 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":""}`)) + })) + 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"}`)) + require.Error(t, err) +}