From 388131c8cd0113d51da0621194e4af6620ebc684 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Wed, 6 May 2026 22:46:11 +0200 Subject: [PATCH] feat(tools): pr_list --- internal/gitea/pulls.go | 27 ++++++++++++ internal/gitea/pulls_test.go | 18 ++++++++ internal/tools/pr_list.go | 80 ++++++++++++++++++++++++++++++++++ internal/tools/pr_list_test.go | 62 ++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 internal/tools/pr_list.go create mode 100644 internal/tools/pr_list_test.go diff --git a/internal/gitea/pulls.go b/internal/gitea/pulls.go index a01b464..694222c 100644 --- a/internal/gitea/pulls.go +++ b/internal/gitea/pulls.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" ) type PullRequest struct { @@ -101,3 +102,29 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, ind } return resp.Body, nil } + +func (c *Client) ListPullRequests(ctx context.Context, owner, repo, state, head string, page, limit int) ([]PullRequest, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 30 + } + p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls?state=%s&page=%d&limit=%d", + owner, repo, url.QueryEscape(state), page, limit) + if head != "" { + p += "&head=" + url.QueryEscape(head) + } + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var prs []PullRequest + if err := json.Unmarshal(body, &prs); err != nil { + return nil, err + } + return prs, nil +} diff --git a/internal/gitea/pulls_test.go b/internal/gitea/pulls_test.go index f2dbb71..c2c6f75 100644 --- a/internal/gitea/pulls_test.go +++ b/internal/gitea/pulls_test.go @@ -136,3 +136,21 @@ func TestGetPullRequestDiff(t *testing.T) { require.NoError(t, err) assert.Equal(t, []byte(rawDiff), diff) } + +func TestListPullRequests(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/pulls", r.URL.Path) + assert.Equal(t, "open", r.URL.Query().Get("state")) + assert.Equal(t, "feat/x", r.URL.Query().Get("head")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[` + pullFixture + `]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + prs, err := c.ListPullRequests(context.Background(), "o", "r", "open", "feat/x", 0, 0) + require.NoError(t, err) + require.Len(t, prs, 1) + assert.Equal(t, 7, prs[0].Number) + assert.Equal(t, "feat/x", prs[0].Head.Ref) +} diff --git a/internal/tools/pr_list.go b/internal/tools/pr_list.go new file mode 100644 index 0000000..b2ecb0d --- /dev/null +++ b/internal/tools/pr_list.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + "encoding/json" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type PRList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewPRList(c *gitea.Client, a *allowlist.Allowlist) *PRList { + return &PRList{c: c, a: a} +} + +func (t *PRList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "pr_list", + Description: "List pull requests. state: open (default), closed, or all. Optionally filter by head branch.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "state":{"type":"string","enum":["open","closed","all"]}, + "head":{"type":"string"}, + "page":{"type":"integer","minimum":1}, + "limit":{"type":"integer","minimum":1,"maximum":50} + }, + "required":["owner","name"] + }`), + } +} + +type prListArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + State string `json:"state"` + Head string `json:"head"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args prListArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + state := args.State + if state == "" { + state = "open" + } + + prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, state, args.Head, args.Page, capLimit(args.Limit, 30)) + if err != nil { + return nil, err + } + + result := make([]map[string]any, len(prs)) + for i, pr := range prs { + result[i] = map[string]any{ + "number": pr.Number, + "title": pr.Title, + "state": pr.State, + "head_branch": pr.Head.Ref, + "base_branch": pr.Base.Ref, + "draft": pr.Draft, + "html_url": pr.HTMLURL, + } + } + return textOK(result) +} diff --git a/internal/tools/pr_list_test.go b/internal/tools/pr_list_test.go new file mode 100644 index 0000000..fbf719d --- /dev/null +++ b/internal/tools/pr_list_test.go @@ -0,0 +1,62 @@ +package tools_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPRListReturnsOpenPRs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "open", r.URL.Query().Get("state")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{ + "number":7,"title":"Add feature X","html_url":"http://example.com/pulls/7", + "state":"open","draft":false, + "head":{"ref":"feat/x"},"base":{"ref":"main"} + }]`)) + })) + defer srv.Close() + + tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`)) + require.NoError(t, err) + + var result []map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + require.Len(t, result, 1) + assert.Equal(t, float64(7), result[0]["number"]) + assert.Equal(t, "feat/x", result[0]["head_branch"]) + assert.Equal(t, "main", result[0]["base_branch"]) +} + +func TestPRListDefaultsToOpen(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "open", r.URL.Query().Get("state")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`)) + require.NoError(t, err) + + var result []map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Empty(t, result) +} + +func TestPRListAllowlistRejects(t *testing.T) { + tool := tools.NewPRList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo"}`)) + require.Error(t, err) +}