feat(tools): add repo_create, repo_update, repo_mirror_push (#12, #13, #16)
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped

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
This commit is contained in:
Mathias Bergqvist
2026-05-15 10:14:18 +02:00
parent 7178ae32be
commit e2da495581
11 changed files with 715 additions and 0 deletions

71
internal/gitea/mirrors.go Normal file
View File

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

View File

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

View File

@@ -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)

View File

@@ -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) {