@@ -65,6 +65,9 @@ func main() {
|
|||||||
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ type Issue struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Labels []Label `json:"labels"`
|
||||||
|
Assignees []User `json:"assignees"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Label struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Login string `json:"login"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateIssueArgs struct {
|
type CreateIssueArgs struct {
|
||||||
@@ -22,6 +36,22 @@ type CreateIssueArgs struct {
|
|||||||
Milestone int64 `json:"milestone,omitempty"`
|
Milestone int64 `json:"milestone,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
||||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||||
payload, err := json.Marshal(args)
|
payload, err := json.Marshal(args)
|
||||||
|
|||||||
@@ -45,6 +45,37 @@ func TestCreateIssue(t *testing.T) {
|
|||||||
assert.Equal(t, "open", iss.State)
|
assert.Equal(t, "open", iss.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetIssue(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/o/r/issues/42", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, iss.Number)
|
||||||
|
assert.Equal(t, "fix auth", iss.Title)
|
||||||
|
assert.Equal(t, "open", iss.State)
|
||||||
|
assert.Equal(t, 3, iss.Comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIssue_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.GetIssue(context.Background(), "o", "r", 999)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateIssueComment(t *testing.T) {
|
func TestCreateIssueComment(t *testing.T) {
|
||||||
var captured []byte
|
var captured []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) {
|
||||||
|
|||||||
@@ -52,6 +52,59 @@ func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive
|
|||||||
return &t, nil
|
return &t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Release struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReleaseArgs struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Draft bool `json:"draft,omitempty"`
|
||||||
|
Prerelease bool `json:"prerelease,omitempty"`
|
||||||
|
// Target branch or commit SHA for tag creation. Empty = repo default branch.
|
||||||
|
Target string `json:"target_commitish,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateRelease(ctx context.Context, owner, repo string, args CreateReleaseArgs) (*Release, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", 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 r Release
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo)
|
||||||
|
resp, status, err := c.DeleteJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
|
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
|
||||||
body, err := json.Marshal(map[string][]string{"topics": topics})
|
body, err := json.Marshal(map[string][]string{"topics": topics})
|
||||||
|
|||||||
@@ -136,6 +136,40 @@ func TestUpdateTopics(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateRelease(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/releases", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
|
||||||
|
TagName: "v1.0.0",
|
||||||
|
Name: "v1.0.0",
|
||||||
|
Body: "first release",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "v1.0.0", rel.TagName)
|
||||||
|
assert.Equal(t, "first release", rel.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRepo(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", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.DeleteRepo(context.Background(), "mathias", "infra")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
||||||
var hits int32
|
var hits int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
54
internal/tools/issue_get.go
Normal file
54
internal/tools/issue_get.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 IssueGet struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
|
||||||
|
|
||||||
|
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_get",
|
||||||
|
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","name","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueGetArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueGetArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iss, err := t.c.GetIssue(ctx, args.Owner, args.Name, args.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(iss)
|
||||||
|
}
|
||||||
50
internal/tools/issue_get_test.go
Normal file
50
internal/tools/issue_get_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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 TestIssueGetTool(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/issues/42", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"number":42`)
|
||||||
|
assert.Contains(t, string(out), `"title":"fix auth"`)
|
||||||
|
assert.Contains(t, string(out), `"comments":3`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueGetTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueGetAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
73
internal/tools/release_create.go
Normal file
73
internal/tools/release_create.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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 ReleaseCreate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReleaseCreate(c *gitea.Client, a *allowlist.Allowlist) *ReleaseCreate {
|
||||||
|
return &ReleaseCreate{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReleaseCreate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "release_create",
|
||||||
|
Description: "Create a release (and tag if it doesn't exist) for a repository.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"tag_name":{"type":"string","description":"Tag to create or use, e.g. 'v1.0.0'."},
|
||||||
|
"release_name":{"type":"string","description":"Display name for the release."},
|
||||||
|
"body":{"type":"string","description":"Release notes / changelog."},
|
||||||
|
"draft":{"type":"boolean"},
|
||||||
|
"prerelease":{"type":"boolean"},
|
||||||
|
"target":{"type":"string","description":"Branch or commit SHA to tag. Defaults to repo default branch."}
|
||||||
|
},
|
||||||
|
"required":["owner","name","tag_name"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseCreateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
ReleaseName string `json:"release_name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args releaseCreateArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Name, gitea.CreateReleaseArgs{
|
||||||
|
TagName: args.TagName,
|
||||||
|
Name: args.ReleaseName,
|
||||||
|
Body: args.Body,
|
||||||
|
Draft: args.Draft,
|
||||||
|
Prerelease: args.Prerelease,
|
||||||
|
Target: args.Target,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(rel)
|
||||||
|
}
|
||||||
38
internal/tools/release_create_test.go
Normal file
38
internal/tools/release_create_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 TestReleaseCreateTool(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/releases", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"changelog","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewReleaseCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","tag_name":"v1.0.0","release_name":"v1.0.0","body":"changelog"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"tag_name":"v1.0.0"`)
|
||||||
|
assert.Contains(t, string(out), `"html_url"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCreateAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewReleaseCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","tag_name":"v1.0.0"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
59
internal/tools/repo_delete.go
Normal file
59
internal/tools/repo_delete.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 RepoDelete struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoDelete(c *gitea.Client, a *allowlist.Allowlist) *RepoDelete {
|
||||||
|
return &RepoDelete{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoDelete) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "repo_delete",
|
||||||
|
Description: "Permanently delete a repository. Requires confirm=<repo name> to prevent accidents.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"confirm":{"type":"string","description":"Must equal the repo name exactly to proceed."}
|
||||||
|
},
|
||||||
|
"required":["owner","name","confirm"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoDeleteArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args repoDeleteArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Confirm != args.Name {
|
||||||
|
return nil, fmt.Errorf("repo_delete requires confirm=%q to match the repo name — got %q", args.Name, args.Confirm)
|
||||||
|
}
|
||||||
|
if err := t.c.DeleteRepo(ctx, args.Owner, args.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(map[string]string{"status": "deleted", "repo": args.Owner + "/" + args.Name})
|
||||||
|
}
|
||||||
52
internal/tools/repo_delete_test.go
Normal file
52
internal/tools/repo_delete_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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 TestRepoDeleteTool_WithCorrectConfirm(t *testing.T) {
|
||||||
|
deleted := false
|
||||||
|
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", r.URL.Path)
|
||||||
|
deleted = true
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"infra"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, deleted, "DELETE request must have been sent")
|
||||||
|
assert.Contains(t, string(out), "deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDeleteTool_WrongConfirmBlocked(t *testing.T) {
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"wrong"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDeleteTool_MissingConfirmBlocked(t *testing.T) {
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDeleteAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","confirm":"x"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user