Compare commits
11 Commits
43e016e8fa
...
1f9934349b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f9934349b | ||
|
|
4274b48ea5 | ||
|
|
fb473262ba | ||
|
|
39dc22ec3a | ||
|
|
e95e87e8e3 | ||
|
|
d3d0fed6b1 | ||
|
|
c8a353aa35 | ||
|
|
6f43ff216f | ||
|
|
2c6b9986e4 | ||
|
|
e4a9d058f0 | ||
|
|
61cce37ff5 |
@@ -35,6 +35,13 @@ func main() {
|
|||||||
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
||||||
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -4,6 +4,7 @@ go 1.26.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,5 +1,7 @@
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
|||||||
@@ -6,23 +6,41 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
token string
|
token string
|
||||||
hc *http.Client
|
hc *http.Client
|
||||||
|
branchCache *expirable.LRU[string, string]
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(baseURL, token string) *Client {
|
func NewClient(baseURL, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
token: token,
|
token: token,
|
||||||
hc: &http.Client{Timeout: 30 * time.Second},
|
hc: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
branchCache: expirable.NewLRU[string, string](64, nil, 60*time.Second),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
||||||
|
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
||||||
|
key := owner + "/" + name
|
||||||
|
if v, ok := c.branchCache.Get(key); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
repo, err := c.GetRepo(ctx, owner, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
c.branchCache.Add(key, repo.DefaultBranch)
|
||||||
|
return repo.DefaultBranch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
reader = bytes.NewReader(body)
|
reader = bytes.NewReader(body)
|
||||||
@@ -48,6 +66,15 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
|
|||||||
return b, resp.StatusCode, err
|
return b, resp.StatusCode, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||||
|
b, status, err := c.doOnce(ctx, method, path, body)
|
||||||
|
if err == nil && method == http.MethodGet && status >= 500 && status < 600 {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
return c.doOnce(ctx, method, path, body)
|
||||||
|
}
|
||||||
|
return b, status, err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) {
|
func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) {
|
||||||
return c.do(ctx, http.MethodGet, path, nil)
|
return c.do(ctx, http.MethodGet, path, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
@@ -27,3 +28,37 @@ func TestClientGetsTokenInHeader(t *testing.T) {
|
|||||||
assert.Contains(t, string(body), `"ok":true`)
|
assert.Contains(t, string(body), `"ok":true`)
|
||||||
assert.Equal(t, "token test-token", gotAuth)
|
assert.Equal(t, "token test-token", gotAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
|
||||||
|
var attempts int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&attempts, 1)
|
||||||
|
if n == 1 {
|
||||||
|
http.Error(w, "boom", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
body, status, err := c.GetJSON(context.Background(), "/api/v1/test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 200, status)
|
||||||
|
assert.Contains(t, string(body), `"ok":true`)
|
||||||
|
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryOnPostNotRetried(t *testing.T) {
|
||||||
|
var attempts int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
atomic.AddInt32(&attempts, 1)
|
||||||
|
http.Error(w, "boom", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, _, _ = c.PostJSON(context.Background(), "/api/v1/test", []byte(`{}`))
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(&attempts), "POST should not retry")
|
||||||
|
}
|
||||||
|
|||||||
43
internal/gitea/code_search.go
Normal file
43
internal/gitea/code_search.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CodeSearchHit struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Score float64 `json:"score,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codeSearchEnvelope struct {
|
||||||
|
Data []CodeSearchHit `json:"data"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
|
||||||
|
owner, repo, url.QueryEscape(q), page, limit)
|
||||||
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var env codeSearchEnvelope
|
||||||
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return env.Data, nil
|
||||||
|
}
|
||||||
39
internal/gitea/code_search_test.go
Normal file
39
internal/gitea/code_search_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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 TestSearchCode(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
||||||
|
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
|
||||||
|
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"data":[{
|
||||||
|
"path":"internal/gitea/code_search.go",
|
||||||
|
"snippet":"func (c *Client) SearchCode",
|
||||||
|
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
|
||||||
|
"score":2.5
|
||||||
|
}],
|
||||||
|
"ok":true
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, hits, 1)
|
||||||
|
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
|
||||||
|
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
|
||||||
|
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
|
||||||
|
}
|
||||||
71
internal/gitea/issues.go
Normal file
71
internal/gitea/issues.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Issue struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateIssueArgs struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Labels []int64 `json:"labels,omitempty"`
|
||||||
|
Assignees []string `json:"assignees,omitempty"`
|
||||||
|
Milestone int64 `json:"milestone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
payload, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PostJSON(ctx, p, payload)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type IssueComment struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
|
||||||
|
// works for both issues and pull requests (PRs share index space with issues).
|
||||||
|
func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, index int, body string) (*IssueComment, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
|
||||||
|
payload, err := json.Marshal(map[string]string{"body": body})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
respBody, status, err := c.PostJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, respBody); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c2 IssueComment
|
||||||
|
if err := json.Unmarshal(respBody, &c2); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c2, nil
|
||||||
|
}
|
||||||
72
internal/gitea/issues_test.go
Normal file
72
internal/gitea/issues_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"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 TestCreateIssue(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"x","body":"y","html_url":"http://example.com/issues/42","state":"open"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
iss, err := c.CreateIssue(context.Background(), "o", "r", gitea.CreateIssueArgs{
|
||||||
|
Title: "x",
|
||||||
|
Body: "y",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "x", payload["title"])
|
||||||
|
assert.Equal(t, "y", payload["body"])
|
||||||
|
|
||||||
|
assert.Equal(t, 42, iss.Number)
|
||||||
|
assert.Equal(t, "x", iss.Title)
|
||||||
|
assert.Equal(t, "y", iss.Body)
|
||||||
|
assert.Equal(t, "http://example.com/issues/42", iss.HTMLURL)
|
||||||
|
assert.Equal(t, "open", iss.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIssueComment(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
comment, err := c.CreateIssueComment(context.Background(), "o", "r", 42, "hello")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "hello", payload["body"])
|
||||||
|
|
||||||
|
assert.Equal(t, int64(7), comment.ID)
|
||||||
|
assert.Equal(t, "hello", comment.Body)
|
||||||
|
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
|
||||||
|
}
|
||||||
@@ -64,3 +64,40 @@ func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, index i
|
|||||||
}
|
}
|
||||||
return &pr, nil
|
return &pr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PullRequestFile struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Status string `json:"status"` // added | modified | deleted | renamed
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, index int) ([]PullRequestFile, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/files", owner, repo, index)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var files []PullRequestFile
|
||||||
|
if err := json.Unmarshal(body, &files); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullRequestDiff returns the raw unified diff. The endpoint serves text/plain, not JSON,
|
||||||
|
// so we use doRaw to bypass the json Accept header expectation.
|
||||||
|
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, index int) ([]byte, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d.diff", owner, repo, index)
|
||||||
|
resp, err := c.doRaw(ctx, "GET", p, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(resp.Status, resp.Body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,3 +93,46 @@ func TestGetPullRequest(t *testing.T) {
|
|||||||
assert.Equal(t, "open", pr.State)
|
assert.Equal(t, "open", pr.State)
|
||||||
assert.True(t, pr.Draft)
|
assert.True(t, pr.Draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetPullRequestFiles(t *testing.T) {
|
||||||
|
filesJSON := `[
|
||||||
|
{"filename":"main.go","status":"modified","additions":10,"deletions":5},
|
||||||
|
{"filename":"README.md","status":"added","additions":20,"deletions":0}
|
||||||
|
]`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/pulls/42/files", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(filesJSON))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
files, err := c.GetPullRequestFiles(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, files, 2)
|
||||||
|
assert.Equal(t, "main.go", files[0].Filename)
|
||||||
|
assert.Equal(t, "modified", files[0].Status)
|
||||||
|
assert.Equal(t, 10, files[0].Additions)
|
||||||
|
assert.Equal(t, 5, files[0].Deletions)
|
||||||
|
assert.Equal(t, "README.md", files[1].Filename)
|
||||||
|
assert.Equal(t, "added", files[1].Status)
|
||||||
|
assert.Equal(t, 20, files[1].Additions)
|
||||||
|
assert.Equal(t, 0, files[1].Deletions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPullRequestDiff(t *testing.T) {
|
||||||
|
rawDiff := "diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1,2 +1,3 @@\n+package main\n"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/pulls/42.diff", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, _ = w.Write([]byte(rawDiff))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
diff, err := c.GetPullRequestDiff(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte(rawDiff), diff)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repo struct {
|
type Repo struct {
|
||||||
@@ -14,6 +15,7 @@ type Repo struct {
|
|||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
CloneURL string `json:"clone_url"`
|
CloneURL string `json:"clone_url"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
Template bool `json:"template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
||||||
@@ -38,6 +40,37 @@ func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) (
|
|||||||
return repos, nil
|
return repos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type repoSearchEnvelope struct {
|
||||||
|
Data []Repo `json:"data"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit int) ([]Repo, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/search?q=%s&page=%d&limit=%d",
|
||||||
|
url.QueryEscape(q), page, limit)
|
||||||
|
if owner != "" {
|
||||||
|
path += "&owner=" + url.QueryEscape(owner)
|
||||||
|
}
|
||||||
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var env repoSearchEnvelope
|
||||||
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return env.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
body, status, err := c.GetJSON(ctx, path)
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
@@ -11,6 +12,23 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSearchRepos(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
|
||||||
|
assert.Equal(t, "infra", r.URL.Query().Get("q"))
|
||||||
|
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
repos, err := c.SearchRepos(context.Background(), "infra", "mathias", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, repos, 1)
|
||||||
|
assert.Equal(t, "mathias/infra", repos[0].FullName)
|
||||||
|
}
|
||||||
|
|
||||||
func TestListRepos(t *testing.T) {
|
func TestListRepos(t *testing.T) {
|
||||||
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, "/api/v1/users/mathias/repos", r.URL.Path)
|
assert.Equal(t, "/api/v1/users/mathias/repos", r.URL.Path)
|
||||||
@@ -28,3 +46,23 @@ func TestListRepos(t *testing.T) {
|
|||||||
assert.Equal(t, "mathias/infra", repos[0].FullName)
|
assert.Equal(t, "mathias/infra", repos[0].FullName)
|
||||||
assert.Equal(t, "main", repos[0].DefaultBranch)
|
assert.Equal(t, "main", repos[0].DefaultBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
||||||
|
var hits int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
atomic.AddInt32(&hits, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"o/infra","default_branch":"trunk"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
b, err := c.DefaultBranch(context.Background(), "o", "infra")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "trunk", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "5 calls should cause exactly 1 server hit due to cache")
|
||||||
|
}
|
||||||
|
|||||||
72
internal/gitea/templates.go
Normal file
72
internal/gitea/templates.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateFromTemplateArgs is the request body for POST /repos/{owner}/{repo}/generate.
|
||||||
|
type GenerateFromTemplateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
|
GitContent bool `json:"git_content"` // include all template files
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateFromTemplate creates a new repository from a template via POST /repos/{tmplOwner}/{tmplName}/generate.
|
||||||
|
func (c *Client) GenerateFromTemplate(ctx context.Context, tmplOwner, tmplName string, args GenerateFromTemplateArgs) (*Repo, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/generate", tmplOwner, tmplName)
|
||||||
|
payload, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PostJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubstituteFile reads a file from the given branch, applies string replacements,
|
||||||
|
// and writes it back if any changes were made. Best-effort — returns a wrapped error
|
||||||
|
// that includes the file path.
|
||||||
|
func (c *Client) SubstituteFile(ctx context.Context, owner, repo, branch, path string, replacements map[string]string) error {
|
||||||
|
fc, err := c.GetFileContents(ctx, owner, repo, path, branch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode %s: %w", path, err)
|
||||||
|
}
|
||||||
|
content := string(decoded)
|
||||||
|
for k, v := range replacements {
|
||||||
|
content = strings.ReplaceAll(content, k, v)
|
||||||
|
}
|
||||||
|
if content == string(decoded) {
|
||||||
|
return nil // no changes, skip write
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
||||||
|
_, err = c.UpsertFile(ctx, owner, repo, path, UpsertFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
Content: encoded,
|
||||||
|
Message: "Apply template substitutions",
|
||||||
|
Sha: fc.Sha,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
156
internal/gitea/templates_test.go
Normal file
156
internal/gitea/templates_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateFromTemplate(t *testing.T) {
|
||||||
|
var capturedBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/template-go-web/generate", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
var err error
|
||||||
|
capturedBody, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"name":"new-svc",
|
||||||
|
"full_name":"mathias/new-svc",
|
||||||
|
"default_branch":"main",
|
||||||
|
"description":"A new service",
|
||||||
|
"private":true,
|
||||||
|
"clone_url":"http://gitea.example.com/mathias/new-svc.git",
|
||||||
|
"html_url":"http://gitea.example.com/mathias/new-svc",
|
||||||
|
"template":false
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
repo, err := c.GenerateFromTemplate(context.Background(), "mathias", "template-go-web", gitea.GenerateFromTemplateArgs{
|
||||||
|
Owner: "mathias",
|
||||||
|
Name: "new-svc",
|
||||||
|
Description: "A new service",
|
||||||
|
Private: true,
|
||||||
|
GitContent: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the captured POST body contains the expected fields.
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(capturedBody, &payload))
|
||||||
|
assert.Equal(t, "mathias", payload["owner"])
|
||||||
|
assert.Equal(t, "new-svc", payload["name"])
|
||||||
|
assert.Equal(t, "A new service", payload["description"])
|
||||||
|
assert.Equal(t, true, payload["private"])
|
||||||
|
assert.Equal(t, true, payload["git_content"])
|
||||||
|
|
||||||
|
// Verify the decoded repo fields.
|
||||||
|
assert.Equal(t, "new-svc", repo.Name)
|
||||||
|
assert.Equal(t, "mathias/new-svc", repo.FullName)
|
||||||
|
assert.Equal(t, "main", repo.DefaultBranch)
|
||||||
|
assert.Equal(t, "A new service", repo.Description)
|
||||||
|
assert.True(t, repo.Private)
|
||||||
|
assert.Equal(t, "http://gitea.example.com/mathias/new-svc.git", repo.CloneURL)
|
||||||
|
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", repo.HTMLURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstituteFileApplies(t *testing.T) {
|
||||||
|
originalContent := "module __MODULE_PATH__\n\ngo 1.22\n"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
|
||||||
|
|
||||||
|
var capturedPutBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
|
||||||
|
assert.Equal(t, "main", r.URL.Query().Get("ref"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":30,"content":"` + encoded + `","encoding":"base64"}`))
|
||||||
|
case http.MethodPut:
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
capturedPutBody, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"commitsha","html_url":""}}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected method %s", r.Method)
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the PUT body contains the substituted content.
|
||||||
|
require.NotNil(t, capturedPutBody, "PUT should have been called")
|
||||||
|
var payload map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(capturedPutBody, &payload))
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(payload["content"])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(decoded), "gitea.d-ma.be/mathias/new-svc")
|
||||||
|
assert.NotContains(t, string(decoded), "__MODULE_PATH__")
|
||||||
|
assert.Equal(t, "abc123", payload["sha"])
|
||||||
|
assert.Equal(t, "Apply template substitutions", payload["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstituteFileNoChangeSkipsWrite(t *testing.T) {
|
||||||
|
originalContent := "module gitea.d-ma.be/mathias/existing\n\ngo 1.22\n"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
|
||||||
|
|
||||||
|
var putCount atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":40,"content":"` + encoded + `","encoding":"base64"}`))
|
||||||
|
case http.MethodPut:
|
||||||
|
putCount.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
// Replacements that don't match anything in the content.
|
||||||
|
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int32(0), putCount.Load(), "PUT should not be called when content is unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstituteFileReadError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"file not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, gitea.ErrNotFound), "error should wrap ErrNotFound, got: %v", err)
|
||||||
|
}
|
||||||
189
internal/tools/code_search.go
Normal file
189
internal/tools/code_search.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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 semaphore chan struct{}
|
||||||
|
|
||||||
|
func newSem(n int) semaphore { return make(semaphore, n) }
|
||||||
|
func (s semaphore) acquire() { s <- struct{}{} }
|
||||||
|
func (s semaphore) release() { <-s }
|
||||||
|
|
||||||
|
type CodeSearch struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCodeSearch(c *gitea.Client, a *allowlist.Allowlist) *CodeSearch {
|
||||||
|
return &CodeSearch{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "code_search",
|
||||||
|
Description: "Search code across one repo or fan out across an owner's repos.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"q":{"type":"string"},
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"page":{"type":"integer","minimum":1},
|
||||||
|
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||||
|
},
|
||||||
|
"required":["q","owner"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type codeSearchArgs struct {
|
||||||
|
Q string `json:"q"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codeSearchResult struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args codeSearchArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Q == "" {
|
||||||
|
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Page < 1 {
|
||||||
|
args.Page = 1
|
||||||
|
}
|
||||||
|
args.Limit = capLimit(args.Limit, 30)
|
||||||
|
|
||||||
|
if args.Repo != "" {
|
||||||
|
return t.singleRepo(ctx, args)
|
||||||
|
}
|
||||||
|
return t.fanOut(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CodeSearch) singleRepo(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
|
||||||
|
hits, err := t.c.SearchCode(ctx, args.Owner, args.Repo, args.Q, args.Page, args.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]codeSearchResult, 0, len(hits))
|
||||||
|
repoFull := args.Owner + "/" + args.Repo
|
||||||
|
for _, h := range hits {
|
||||||
|
score := h.Score
|
||||||
|
if score == 0 {
|
||||||
|
score = 1.0
|
||||||
|
}
|
||||||
|
results = append(results, codeSearchResult{
|
||||||
|
Repo: repoFull,
|
||||||
|
Path: h.Path,
|
||||||
|
Snippet: h.Snippet,
|
||||||
|
Score: score,
|
||||||
|
HTMLURL: h.HTMLURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out := map[string]any{"results": results}
|
||||||
|
if len(hits) == args.Limit {
|
||||||
|
out["next_page"] = args.Page + 1
|
||||||
|
}
|
||||||
|
return textOK(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CodeSearch) fanOut(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
|
||||||
|
repos, err := t.c.ListRepos(ctx, args.Owner, 1, 50)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoResult struct {
|
||||||
|
repo string
|
||||||
|
hits []gitea.CodeSearchHit
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultsCh := make(chan repoResult, len(repos))
|
||||||
|
sem := newSem(5)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, r := range repos {
|
||||||
|
repo := r // capture
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem.acquire()
|
||||||
|
defer sem.release()
|
||||||
|
|
||||||
|
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
hits, err := t.c.SearchCode(rctx, args.Owner, repo.Name, args.Q, 1, args.Limit)
|
||||||
|
resultsCh <- repoResult{repo: args.Owner + "/" + repo.Name, hits: hits, err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(resultsCh)
|
||||||
|
|
||||||
|
merged := make([]codeSearchResult, 0)
|
||||||
|
var partialRepos []string
|
||||||
|
for rr := range resultsCh {
|
||||||
|
if rr.err != nil {
|
||||||
|
partialRepos = append(partialRepos, rr.repo)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, h := range rr.hits {
|
||||||
|
score := h.Score
|
||||||
|
if score == 0 {
|
||||||
|
score = 1.0
|
||||||
|
}
|
||||||
|
merged = append(merged, codeSearchResult{
|
||||||
|
Repo: rr.repo, Path: h.Path, Snippet: h.Snippet, Score: score, HTMLURL: h.HTMLURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score desc, then by repo+path for determinism.
|
||||||
|
sort.Slice(merged, func(i, j int) bool {
|
||||||
|
if merged[i].Score != merged[j].Score {
|
||||||
|
return merged[i].Score > merged[j].Score
|
||||||
|
}
|
||||||
|
if merged[i].Repo != merged[j].Repo {
|
||||||
|
return merged[i].Repo < merged[j].Repo
|
||||||
|
}
|
||||||
|
return merged[i].Path < merged[j].Path
|
||||||
|
})
|
||||||
|
if len(merged) > args.Limit {
|
||||||
|
merged = merged[:args.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{
|
||||||
|
"results": merged,
|
||||||
|
"partial": len(partialRepos) > 0,
|
||||||
|
}
|
||||||
|
if len(partialRepos) > 0 {
|
||||||
|
sort.Strings(partialRepos)
|
||||||
|
out["partial_repos"] = partialRepos
|
||||||
|
}
|
||||||
|
return textOK(out)
|
||||||
|
}
|
||||||
187
internal/tools/code_search_test.go
Normal file
187
internal/tools/code_search_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"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 TestCodeSearchSingleRepo(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
||||||
|
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
|
||||||
|
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"data":[{
|
||||||
|
"path":"internal/gitea/repos.go",
|
||||||
|
"snippet":"func (c *Client) ListRepos",
|
||||||
|
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
|
||||||
|
"score":3.0
|
||||||
|
}],
|
||||||
|
"ok":true
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"ListRepos","owner":"mathias","repo":"infra"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
require.Len(t, result.Results, 1)
|
||||||
|
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||||
|
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
||||||
|
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"foo","owner":"evil","repo":"infra"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeSearchRequiresQ(t *testing.T) {
|
||||||
|
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, gitea.ErrValidation))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/users/mathias/repos":
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||||
|
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
|
||||||
|
]`))
|
||||||
|
case "/api/v1/repos/mathias/infra/search":
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
|
||||||
|
case "/api/v1/repos/mathias/gitea-mcp/search":
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
} `json:"results"`
|
||||||
|
Partial bool `json:"partial"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.False(t, result.Partial)
|
||||||
|
require.Len(t, result.Results, 2)
|
||||||
|
|
||||||
|
repos := make([]string, 0, 2)
|
||||||
|
for _, r := range result.Results {
|
||||||
|
repos = append(repos, r.Repo)
|
||||||
|
}
|
||||||
|
assert.Contains(t, repos, "mathias/infra")
|
||||||
|
assert.Contains(t, repos, "mathias/gitea-mcp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/users/mathias/repos":
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||||
|
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
|
||||||
|
]`))
|
||||||
|
case "/api/v1/repos/mathias/infra/search":
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
|
||||||
|
case "/api/v1/repos/mathias/broken/search":
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []struct{ Repo string `json:"repo"` } `json:"results"`
|
||||||
|
Partial bool `json:"partial"`
|
||||||
|
PartialRepos []string `json:"partial_repos"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.True(t, result.Partial)
|
||||||
|
require.Len(t, result.PartialRepos, 1)
|
||||||
|
assert.Equal(t, "mathias/broken", result.PartialRepos[0])
|
||||||
|
require.Len(t, result.Results, 1)
|
||||||
|
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/users/mathias/repos":
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
|
||||||
|
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
|
||||||
|
]`))
|
||||||
|
case "/api/v1/repos/mathias/alpha/search":
|
||||||
|
// low score
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
|
||||||
|
case "/api/v1/repos/mathias/beta/search":
|
||||||
|
// high score
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []struct {
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
require.Len(t, result.Results, 2)
|
||||||
|
// First result must be the high-score one
|
||||||
|
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
||||||
|
"expected results sorted by score desc, got %v then %v",
|
||||||
|
result.Results[0].Score, result.Results[1].Score)
|
||||||
|
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
|
||||||
|
}
|
||||||
146
internal/tools/create_project_from_template.go
Normal file
146
internal/tools/create_project_from_template.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||||
|
|
||||||
|
var substitutionFiles = []string{
|
||||||
|
"go.mod",
|
||||||
|
"Taskfile.yml",
|
||||||
|
"Dockerfile",
|
||||||
|
".gitea/workflows/cd.yml",
|
||||||
|
"README.md",
|
||||||
|
".context/PROJECT.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
func substitutions(owner, name string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"__PROJECT_NAME__": name,
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
||||||
|
type CreateProjectFromTemplate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
templateOwner string
|
||||||
|
templateName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplOwner, tmplName string) *CreateProjectFromTemplate {
|
||||||
|
return &CreateProjectFromTemplate{c: c, a: a, templateOwner: tmplOwner, templateName: tmplName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "create_project_from_template",
|
||||||
|
Description: "Create a new project repo from the template, applying placeholder substitutions to known files.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||||
|
"description":{"type":"string"},
|
||||||
|
"private":{"type":"boolean"}
|
||||||
|
},
|
||||||
|
"required":["owner","name"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createProjectArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createProjectResult struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args createProjectArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowlist check first.
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name format.
|
||||||
|
if !nameRe.MatchString(args.Name) {
|
||||||
|
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify template exists and is marked as a template repo.
|
||||||
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("template lookup: %w", err)
|
||||||
|
}
|
||||||
|
if !tmpl.Template {
|
||||||
|
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify destination doesn't already exist.
|
||||||
|
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
|
||||||
|
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
|
||||||
|
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("destination check: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate repo from template.
|
||||||
|
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
|
||||||
|
Owner: args.Owner,
|
||||||
|
Name: args.Name,
|
||||||
|
Description: args.Description,
|
||||||
|
Private: args.Private,
|
||||||
|
GitContent: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := createProjectResult{
|
||||||
|
FullName: newRepo.FullName,
|
||||||
|
HTMLURL: newRepo.HTMLURL,
|
||||||
|
CloneURL: newRepo.CloneURL,
|
||||||
|
DefaultBranch: newRepo.DefaultBranch,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitute placeholders in known files (best-effort).
|
||||||
|
repls := substitutions(args.Owner, args.Name)
|
||||||
|
branch := newRepo.DefaultBranch
|
||||||
|
for _, path := range substitutionFiles {
|
||||||
|
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
|
||||||
|
// Files that don't exist in this template are silently skipped.
|
||||||
|
if errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Any other error halts the substitution pass with partial_failure recorded.
|
||||||
|
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result.FilesSubstituted = append(result.FilesSubstituted, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(result)
|
||||||
|
}
|
||||||
266
internal/tools/create_project_from_template_test.go
Normal file
266
internal/tools/create_project_from_template_test.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
|
||||||
|
var substitutionFileList = []string{
|
||||||
|
"go.mod",
|
||||||
|
"Taskfile.yml",
|
||||||
|
"Dockerfile",
|
||||||
|
".gitea/workflows/cd.yml",
|
||||||
|
"README.md",
|
||||||
|
".context/PROJECT.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentWithPlaceholder is a template file body that contains the placeholder.
|
||||||
|
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
|
||||||
|
|
||||||
|
func encodedContent(s string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileContentsJSON returns a JSON FileContents object for the given path.
|
||||||
|
func fileContentsJSON(path string) string {
|
||||||
|
enc := encodedContent(contentWithPlaceholder)
|
||||||
|
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
|
||||||
|
path, strings.ReplaceAll(path, "/", "-"), enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
|
||||||
|
func fileWriteResultJSON(path string) string {
|
||||||
|
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTemplateRepoJSON returns a JSON Repo marked as template.
|
||||||
|
func newTemplateRepoJSON(name string, isTemplate bool) string {
|
||||||
|
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
||||||
|
name, name, name, name, isTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
|
||||||
|
func newGeneratedRepoJSON(name string) string {
|
||||||
|
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||||
|
name, name, name, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
|
||||||
|
c := gitea.NewClient(srvURL, "tok")
|
||||||
|
a := allowlist.New([]string{"mathias"})
|
||||||
|
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectHappyPath: all 6 files served and substituted.
|
||||||
|
func TestCreateProjectHappyPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
// Template repo lookup
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||||
|
|
||||||
|
// Destination repo lookup — 404 means it doesn't exist yet
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
|
||||||
|
// Generate
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
||||||
|
|
||||||
|
// File contents GET — handle all 6 substitution files
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||||
|
|
||||||
|
// File contents PUT — handle all 6 substitution files
|
||||||
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
|
||||||
|
assert.Equal(t, "mathias/new-svc", out.FullName)
|
||||||
|
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
|
||||||
|
assert.Equal(t, "main", out.DefaultBranch)
|
||||||
|
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
|
||||||
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
||||||
|
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
||||||
|
tool := tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient("http://unused", ""),
|
||||||
|
allowlist.New([]string{"mathias"}),
|
||||||
|
"mathias", "template-go-web",
|
||||||
|
)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
|
||||||
|
func TestCreateProjectAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient("http://unused", ""),
|
||||||
|
allowlist.New([]string{"mathias"}),
|
||||||
|
"mathias", "template-go-web",
|
||||||
|
)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
|
||||||
|
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Template lookup returns a non-template repo.
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectDestinationExists: destination repo already exists.
|
||||||
|
func TestCreateProjectDestinationExists(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||||
|
// Destination exists — return 200.
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
|
||||||
|
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
|
||||||
|
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
|
||||||
|
// Files that should succeed (index 0-2 in substitutionFileList).
|
||||||
|
successFiles := map[string]bool{
|
||||||
|
"go.mod": true,
|
||||||
|
"Taskfile.yml": true,
|
||||||
|
"Dockerfile": true,
|
||||||
|
}
|
||||||
|
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
|
||||||
|
failFile := ".gitea/workflows/cd.yml"
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
if filePath == failFile {
|
||||||
|
// Simulate upstream 500.
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !successFiles[filePath] {
|
||||||
|
t.Errorf("unexpected PUT for file: %s", filePath)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
|
// Best-effort: no Go error returned, partial state in result.
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
|
||||||
|
// First 3 files should be in FilesSubstituted.
|
||||||
|
assert.Len(t, out.FilesSubstituted, 3)
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
|
||||||
|
assert.NotContains(t, out.FilesSubstituted, failFile)
|
||||||
|
|
||||||
|
// partial_failure should be non-empty.
|
||||||
|
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
|
||||||
|
}
|
||||||
@@ -57,11 +57,11 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
|||||||
|
|
||||||
ref := args.Ref
|
ref := args.Ref
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name)
|
var err error
|
||||||
|
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ref = repo.DefaultBranch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
|
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
|
||||||
|
|||||||
@@ -75,11 +75,11 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
|||||||
if !exists {
|
if !exists {
|
||||||
base := args.Base
|
base := args.Base
|
||||||
if base == "" {
|
if base == "" {
|
||||||
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name)
|
var err error
|
||||||
|
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
base = repo.DefaultBranch
|
|
||||||
}
|
}
|
||||||
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
|
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
73
internal/tools/issue_comment.go
Normal file
73
internal/tools/issue_comment.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueComment struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueComment(c *gitea.Client, a *allowlist.Allowlist) *IssueComment {
|
||||||
|
return &IssueComment{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueComment) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_comment",
|
||||||
|
Description: "Comment on an issue. Applies identity footer to body.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1},
|
||||||
|
"body":{"type":"string"}
|
||||||
|
},
|
||||||
|
"required":["owner","name","number","body"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueCommentArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueCommentArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Number < 1 {
|
||||||
|
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if args.Body == "" {
|
||||||
|
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
|
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(map[string]any{
|
||||||
|
"id": c.ID,
|
||||||
|
"html_url": c.HTMLURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
54
internal/tools/issue_comment_test.go
Normal file
54
internal/tools/issue_comment_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const commentFixture = `{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`
|
||||||
|
|
||||||
|
func TestIssueCommentAppliesFooter(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(commentFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
ctx := callerContext("mathiasbq")
|
||||||
|
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":42,"body":"hello"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
body, _ := payload["body"].(string)
|
||||||
|
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCommentAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCommentRequiresBody(t *testing.T) {
|
||||||
|
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
84
internal/tools/issue_create.go
Normal file
84
internal/tools/issue_create.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueCreate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueCreate(c *gitea.Client, a *allowlist.Allowlist) *IssueCreate {
|
||||||
|
return &IssueCreate{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_create",
|
||||||
|
Description: "Create an issue. Applies identity footer to body.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"title":{"type":"string"},
|
||||||
|
"body":{"type":"string"},
|
||||||
|
"labels":{"type":"array","items":{"type":"integer"}},
|
||||||
|
"assignees":{"type":"array","items":{"type":"string"}},
|
||||||
|
"milestone":{"type":"integer"}
|
||||||
|
},
|
||||||
|
"required":["owner","name","title"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueCreateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Labels []int64 `json:"labels"`
|
||||||
|
Assignees []string `json:"assignees"`
|
||||||
|
Milestone int64 `json:"milestone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueCreateArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Title == "" {
|
||||||
|
return nil, fmt.Errorf("title is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
|
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
|
||||||
|
Title: args.Title,
|
||||||
|
Body: body,
|
||||||
|
Labels: args.Labels,
|
||||||
|
Assignees: args.Assignees,
|
||||||
|
Milestone: args.Milestone,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(map[string]any{
|
||||||
|
"number": iss.Number,
|
||||||
|
"title": iss.Title,
|
||||||
|
"html_url": iss.HTMLURL,
|
||||||
|
"state": iss.State,
|
||||||
|
})
|
||||||
|
}
|
||||||
81
internal/tools/issue_create_test.go
Normal file
81
internal/tools/issue_create_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const issueFixture = `{
|
||||||
|
"number": 42,
|
||||||
|
"title": "x",
|
||||||
|
"body": "y",
|
||||||
|
"html_url": "http://example.com/issues/42",
|
||||||
|
"state": "open"
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestIssueCreateAppliesIdentityFooter(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(issueFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
ctx := callerContext("mathiasbq")
|
||||||
|
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
body, _ := payload["body"].(string)
|
||||||
|
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreateNoFooterWhenCallerEmpty(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(issueFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
body, _ := payload["body"].(string)
|
||||||
|
assert.NotContains(t, body, "_Created via git-mcp on behalf of")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreateAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","title":"T"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreateRequiresTitle(t *testing.T) {
|
||||||
|
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":""}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
73
internal/tools/pr_comment.go
Normal file
73
internal/tools/pr_comment.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PRComment struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPRComment(c *gitea.Client, a *allowlist.Allowlist) *PRComment {
|
||||||
|
return &PRComment{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PRComment) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "pr_comment",
|
||||||
|
Description: "Comment on a pull request (conversation, not inline review). Applies identity footer.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1},
|
||||||
|
"body":{"type":"string"}
|
||||||
|
},
|
||||||
|
"required":["owner","name","number","body"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type prCommentArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args prCommentArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Number < 1 {
|
||||||
|
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if args.Body == "" {
|
||||||
|
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
|
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(map[string]any{
|
||||||
|
"id": c.ID,
|
||||||
|
"html_url": c.HTMLURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
53
internal/tools/pr_comment_test.go
Normal file
53
internal/tools/pr_comment_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"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 TestPRCommentAppliesFooter(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// PRs share index space with issues — same endpoint
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/3/comments", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(commentFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewPRComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
ctx := callerContext("mathiasbq")
|
||||||
|
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":3,"body":"looks good"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
body, _ := payload["body"].(string)
|
||||||
|
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRCommentAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRCommentRequiresBody(t *testing.T) {
|
||||||
|
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
171
internal/tools/pr_files_diff.go
Normal file
171
internal/tools/pr_files_diff.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxFileDiffBytes = 20 * 1024
|
||||||
|
maxResponseBytes = 200 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
type PRFilesDiff struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPRFilesDiff(c *gitea.Client, a *allowlist.Allowlist) *PRFilesDiff {
|
||||||
|
return &PRFilesDiff{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PRFilesDiff) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "pr_files_diff",
|
||||||
|
Description: "Get a pull request's per-file diff with size caps (20KB/file, 200KB total).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","name","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type prFilesDiffArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type prFileDiffEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Diff string `json:"diff"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
OmittedLines int `json:"omitted_lines,omitempty"`
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args prFilesDiffArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Number < 1 {
|
||||||
|
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Name, args.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Name, args.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split unified diff by per-file headers ("diff --git a/path b/path")
|
||||||
|
perFile := splitUnifiedDiff(rawDiff)
|
||||||
|
|
||||||
|
out := struct {
|
||||||
|
Files []prFileDiffEntry `json:"files"`
|
||||||
|
OmittedFiles []string `json:"omitted_files,omitempty"`
|
||||||
|
ResponseTruncated bool `json:"response_truncated"`
|
||||||
|
}{
|
||||||
|
Files: make([]prFileDiffEntry, 0, len(files)),
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes := 0
|
||||||
|
for _, f := range files {
|
||||||
|
// look up the diff for this file (best-effort by path match)
|
||||||
|
diffBytes, ok := perFile[f.Filename]
|
||||||
|
if !ok {
|
||||||
|
diffBytes = []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := prFileDiffEntry{
|
||||||
|
Path: f.Filename,
|
||||||
|
Additions: f.Additions,
|
||||||
|
Deletions: f.Deletions,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-file cap
|
||||||
|
if len(diffBytes) > maxFileDiffBytes {
|
||||||
|
truncated := diffBytes[:maxFileDiffBytes]
|
||||||
|
omittedLines := bytes.Count(diffBytes[maxFileDiffBytes:], []byte("\n"))
|
||||||
|
entry.Diff = string(truncated)
|
||||||
|
entry.Truncated = true
|
||||||
|
entry.OmittedLines = omittedLines
|
||||||
|
} else {
|
||||||
|
entry.Diff = string(diffBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response cap — if adding this entry would exceed, push to omitted_files
|
||||||
|
entryEstimate := len(entry.Diff) + 200 // small overhead for path + counts
|
||||||
|
if totalBytes+entryEstimate > maxResponseBytes {
|
||||||
|
out.OmittedFiles = append(out.OmittedFiles, f.Filename)
|
||||||
|
out.ResponseTruncated = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalBytes += entryEstimate
|
||||||
|
out.Files = append(out.Files, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitUnifiedDiff parses a unified diff and returns a map from filename to that file's
|
||||||
|
// portion of the diff. The unified diff format starts each file with a line like
|
||||||
|
// "diff --git a/<path> b/<path>".
|
||||||
|
func splitUnifiedDiff(d []byte) map[string][]byte {
|
||||||
|
m := map[string][]byte{}
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(d))
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // allow long diffs
|
||||||
|
|
||||||
|
var currentFile string
|
||||||
|
var current bytes.Buffer
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if currentFile != "" {
|
||||||
|
m[currentFile] = []byte(current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "diff --git ") {
|
||||||
|
flush()
|
||||||
|
// Parse: "diff --git a/<path> b/<path>"
|
||||||
|
rest := strings.TrimPrefix(line, "diff --git a/")
|
||||||
|
parts := strings.SplitN(rest, " b/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
currentFile = parts[0]
|
||||||
|
} else {
|
||||||
|
currentFile = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if currentFile != "" {
|
||||||
|
current.WriteString(line)
|
||||||
|
current.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return m
|
||||||
|
}
|
||||||
183
internal/tools/pr_files_diff_test.go
Normal file
183
internal/tools/pr_files_diff_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildDiff builds a synthetic unified diff for a set of files.
|
||||||
|
// Each file gets `linesPerFile` added lines.
|
||||||
|
func buildDiff(files []string, linesPerFile int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, f := range files {
|
||||||
|
fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", f, f)
|
||||||
|
fmt.Fprintf(&sb, "--- a/%s\n+++ b/%s\n", f, f)
|
||||||
|
fmt.Fprintf(&sb, "@@ -0,0 +1,%d @@\n", linesPerFile)
|
||||||
|
sb.WriteString(strings.Repeat("+abcdefghij\n", linesPerFile))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFilesJSON builds the JSON list of PullRequestFile objects.
|
||||||
|
func buildFilesJSON(files []string, additions int) string {
|
||||||
|
entries := make([]string, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
entries[i] = fmt.Sprintf(`{"filename":%q,"status":"modified","additions":%d,"deletions":0}`, f, additions)
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(entries, ",") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPRFilesDiffServer creates a test server that serves both the /files and .diff endpoints.
|
||||||
|
func newPRFilesDiffServer(t *testing.T, filesJSON, rawDiff string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/repos/o/r/pulls/1/files":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(filesJSON))
|
||||||
|
case r.URL.Path == "/api/v1/repos/o/r/pulls/1.diff":
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, _ = w.Write([]byte(rawDiff))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRFilesDiffSmall(t *testing.T) {
|
||||||
|
// Two files, each ~120 bytes of diff — well under per-file and total caps.
|
||||||
|
fileNames := []string{"main.go", "util.go"}
|
||||||
|
// ~10 lines each = ~120 bytes per file diff
|
||||||
|
rawDiff := buildDiff(fileNames, 10)
|
||||||
|
filesJSON := buildFilesJSON(fileNames, 10)
|
||||||
|
|
||||||
|
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Files []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Diff string `json:"diff"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
} `json:"files"`
|
||||||
|
OmittedFiles []string `json:"omitted_files"`
|
||||||
|
ResponseTruncated bool `json:"response_truncated"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
|
||||||
|
assert.Len(t, out.Files, 2)
|
||||||
|
assert.Empty(t, out.OmittedFiles)
|
||||||
|
assert.False(t, out.ResponseTruncated)
|
||||||
|
|
||||||
|
for _, f := range out.Files {
|
||||||
|
assert.False(t, f.Truncated, "file %s should not be truncated", f.Path)
|
||||||
|
assert.NotEmpty(t, f.Diff)
|
||||||
|
assert.Equal(t, 10, f.Additions)
|
||||||
|
assert.Equal(t, 0, f.Deletions)
|
||||||
|
}
|
||||||
|
paths := []string{out.Files[0].Path, out.Files[1].Path}
|
||||||
|
assert.ElementsMatch(t, fileNames, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
||||||
|
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
||||||
|
fileNames := []string{"bigfile.go"}
|
||||||
|
linesPerFile := 2560 // ~30720 bytes > 20KB cap
|
||||||
|
rawDiff := buildDiff(fileNames, linesPerFile)
|
||||||
|
filesJSON := buildFilesJSON(fileNames, linesPerFile)
|
||||||
|
|
||||||
|
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Files []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Diff string `json:"diff"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
OmittedLines int `json:"omitted_lines"`
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
} `json:"files"`
|
||||||
|
ResponseTruncated bool `json:"response_truncated"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
|
||||||
|
require.Len(t, out.Files, 1)
|
||||||
|
f := out.Files[0]
|
||||||
|
assert.Equal(t, "bigfile.go", f.Path)
|
||||||
|
assert.True(t, f.Truncated, "file should be truncated")
|
||||||
|
assert.Greater(t, f.OmittedLines, 0, "omitted_lines should be > 0")
|
||||||
|
assert.LessOrEqual(t, len(f.Diff), 20*1024+200, "diff should be capped near 20KB")
|
||||||
|
assert.False(t, out.ResponseTruncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRFilesDiffResponseCapped(t *testing.T) {
|
||||||
|
// 25 files × ~10KB diff each = ~250KB raw, well over the 200KB response cap.
|
||||||
|
// Each file: 850 lines × 12 bytes = 10200 bytes per file.
|
||||||
|
numFiles := 25
|
||||||
|
linesPerFile := 850
|
||||||
|
fileNames := make([]string, numFiles)
|
||||||
|
for i := range fileNames {
|
||||||
|
fileNames[i] = fmt.Sprintf("file%02d.go", i)
|
||||||
|
}
|
||||||
|
rawDiff := buildDiff(fileNames, linesPerFile)
|
||||||
|
filesJSON := buildFilesJSON(fileNames, linesPerFile)
|
||||||
|
|
||||||
|
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Files []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"files"`
|
||||||
|
OmittedFiles []string `json:"omitted_files"`
|
||||||
|
ResponseTruncated bool `json:"response_truncated"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
|
||||||
|
assert.True(t, out.ResponseTruncated, "response should be truncated")
|
||||||
|
assert.NotEmpty(t, out.OmittedFiles, "some files should be omitted")
|
||||||
|
assert.NotEmpty(t, out.Files, "some files should be included")
|
||||||
|
|
||||||
|
// Total files accounted for should equal numFiles.
|
||||||
|
totalAccountedFor := len(out.Files) + len(out.OmittedFiles)
|
||||||
|
assert.Equal(t, numFiles, totalAccountedFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRFilesDiffAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRFilesDiffRequiresValidNumber(t *testing.T) {
|
||||||
|
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":0}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
@@ -48,10 +48,8 @@ func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
|||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Limit == 0 || args.Limit > 50 {
|
args.Limit = capLimit(args.Limit, 30)
|
||||||
args.Limit = 30
|
if args.Page < 1 {
|
||||||
}
|
|
||||||
if args.Page == 0 {
|
|
||||||
args.Page = 1
|
args.Page = 1
|
||||||
}
|
}
|
||||||
repos, err := t.c.ListRepos(ctx, args.Owner, args.Page, args.Limit)
|
repos, err := t.c.ListRepos(ctx, args.Owner, args.Page, args.Limit)
|
||||||
|
|||||||
90
internal/tools/repo_search.go
Normal file
90
internal/tools/repo_search.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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 RepoSearch struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoSearch(c *gitea.Client, a *allowlist.Allowlist) *RepoSearch {
|
||||||
|
return &RepoSearch{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoSearch) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "repo_search",
|
||||||
|
Description: "Search repos by query string. Filters results by owner allowlist.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"q":{"type":"string"},
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"page":{"type":"integer","minimum":1},
|
||||||
|
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||||
|
},
|
||||||
|
"required":["q"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoSearchArgs struct {
|
||||||
|
Q string `json:"q"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args repoSearchArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Q == "" {
|
||||||
|
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if args.Owner != "" {
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args.Page < 1 {
|
||||||
|
args.Page = 1
|
||||||
|
}
|
||||||
|
args.Limit = capLimit(args.Limit, 30)
|
||||||
|
|
||||||
|
repos, err := t.c.SearchRepos(ctx, args.Q, args.Owner, args.Page, args.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-filter when owner not specified — only allowlisted owners survive.
|
||||||
|
if args.Owner == "" {
|
||||||
|
filtered := make([]gitea.Repo, 0, len(repos))
|
||||||
|
for _, r := range repos {
|
||||||
|
parts := strings.SplitN(r.FullName, "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.a.Check(parts[0]) == nil {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repos = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{"repos": repos}
|
||||||
|
if len(repos) == args.Limit {
|
||||||
|
out["next_page"] = args.Page + 1
|
||||||
|
}
|
||||||
|
return textOK(out)
|
||||||
|
}
|
||||||
61
internal/tools/repo_search_test.go
Normal file
61
internal/tools/repo_search_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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 TestRepoSearchWithOwner(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
|
||||||
|
assert.Equal(t, "infra", r.URL.Query().Get("q"))
|
||||||
|
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"mathias"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSearchPostFiltersWithoutOwner(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// No owner param expected when owner is empty
|
||||||
|
assert.Empty(t, r.URL.Query().Get("owner"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"name":"x","full_name":"mathias/x"},{"name":"y","full_name":"evil/y"}],"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"x"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"mathias/x"`)
|
||||||
|
assert.NotContains(t, string(out), `"evil/y"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSearchAllowlistRejectsExplicitOwner(t *testing.T) {
|
||||||
|
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"evil"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSearchRequiresQ(t *testing.T) {
|
||||||
|
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, gitea.ErrValidation))
|
||||||
|
}
|
||||||
@@ -22,3 +22,14 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func _ctx(ctx context.Context) context.Context { return ctx } // stub for future hooks
|
func _ctx(ctx context.Context) context.Context { return ctx } // stub for future hooks
|
||||||
|
|
||||||
|
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50.
|
||||||
|
func capLimit(in, def int) int {
|
||||||
|
if in <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if in > 50 {
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ func (t *WorkflowRunTrigger) Call(ctx context.Context, raw json.RawMessage) (jso
|
|||||||
|
|
||||||
ref := args.Ref
|
ref := args.Ref
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name)
|
var err error
|
||||||
|
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ref = repo.DefaultBranch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := t.c.DispatchWorkflow(ctx, args.Owner, args.Name, args.Workflow, gitea.DispatchWorkflowArgs{
|
result, err := t.c.DispatchWorkflow(ctx, args.Owner, args.Name, args.Workflow, gitea.DispatchWorkflowArgs{
|
||||||
|
|||||||
Reference in New Issue
Block a user