From 813dfc17eb070345620e45b24e3d4dd3d97bfb1a Mon Sep 17 00:00:00 2001 From: pikann22 Date: Tue, 3 Nov 2026 15:18:41 +0000 Subject: [PATCH] feat: implement issue listing functionality and related tests --- backend/spi_assert.go | 1 + backend/spi_fakegitlab_test.go | 6 +++ backend/spi_issues_test.go | 91 ++++++++++++++++++++++++++++++++++ backend/spi_provider.go | 1 + backend/spi_reads.go | 69 ++++++++++++++++++++------ 5 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 backend/spi_issues_test.go diff --git a/backend/spi_assert.go b/backend/spi_assert.go index bd4a1f9..25d026a 100644 --- a/backend/spi_assert.go +++ b/backend/spi_assert.go @@ -19,4 +19,5 @@ var ( _ gp.IdentityDeleter = (*glProvider)(nil) _ gp.BranchManager = (*glProvider)(nil) _ gp.IdentityDescriber = (*glProvider)(nil) + _ gp.IssueLister = (*glProvider)(nil) ) diff --git a/backend/spi_fakegitlab_test.go b/backend/spi_fakegitlab_test.go index 0612460..27bf2d7 100644 --- a/backend/spi_fakegitlab_test.go +++ b/backend/spi_fakegitlab_test.go @@ -578,6 +578,12 @@ func (g *fakeGitLab) noteAPI(kind string, iid int, method string, q url.Values, } func (g *fakeGitLab) issueAPI(method string, rest []string, payload map[string]any) (*plugin.FetchResponse, error) { + if len(rest) == 0 && method == "GET" { + // The listing: the one issue the fake knows. + return resp(200, []map[string]any{{"iid": 7, "title": "Bug", "description": "It breaks", "state": "opened", + "web_url": "https://gitlab.com/acme/web/-/issues/7", "author": map[string]any{"username": "reader"}, + "labels": []string{"bug"}, "assignees": []map[string]any{{"username": "bob"}}}}) + } if len(rest) == 0 { return notFound() } diff --git a/backend/spi_issues_test.go b/backend/spi_issues_test.go new file mode 100644 index 0000000..ec13bbd --- /dev/null +++ b/backend/spi_issues_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "net/url" + "strconv" + "strings" + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +func glIssueRow(n int, state string) map[string]any { + return map[string]any{"iid": n, "title": "Issue " + strconv.Itoa(n), "description": "Body", "state": state, + "web_url": "https://gitlab.com/acme/web/-/issues/" + strconv.Itoa(n), "author": map[string]any{"username": "reader"}, + "labels": []string{"bug"}, "assignees": []map[string]any{{"username": "bob"}}} +} + +func TestListIssuesPagesAndMapsStates(t *testing.T) { + p, g, _ := seededProvider(t) + var seen []url.Values + fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + u, _ := url.Parse(rawURL) + if !strings.HasSuffix(u.EscapedPath(), "/issues") { + return g.fetch(method, rawURL, headers, body) + } + if method != "GET" { + t.Fatalf("unexpected request: %s %s", method, rawURL) + } + seen = append(seen, u.Query()) + switch u.Query().Get("page") { + case "1": // a full page: there may be more + rows := make([]map[string]any, listPerPage) + for i := range rows { + rows[i] = glIssueRow(i+1, "closed") + } + rows[0] = glIssueRow(500, "opened") + return resp(200, rows) + case "2": + return resp(200, []map[string]any{glIssueRow(501, "opened")}) + } + t.Fatalf("unexpected page: %s", rawURL) + return nil, nil + } + + first, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: fixtureTarget, State: "closed"}) + if err != nil || len(first.Items) != listPerPage || first.NextCursor != "2" { + t.Fatalf("first page: %d items, cursor %q, %v", len(first.Items), first.NextCursor, err) + } + if q := seen[0]; q.Get("state") != "closed" || q.Get("per_page") != "100" { + t.Fatalf("query = %v", q) + } + if first.Items[0].State != "open" || first.Items[1].State != "closed" { + t.Fatalf("states: %+v %+v", first.Items[0], first.Items[1]) + } + second, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: fixtureTarget, Cursor: first.NextCursor}) + if err != nil || len(second.Items) != 1 || second.NextCursor != "" { + t.Fatalf("last page: %+v %v", second, err) + } + i := second.Items[0] + if i.Number != "501" || i.Title != "Issue 501" || i.Body != "Body" || i.Author != "reader" || i.URL != "https://gitlab.com/acme/web/-/issues/501" || + len(i.Labels) != 1 || len(i.Assignees) != 1 || i.Assignees[0] != "bob" { + t.Fatalf("issue fields: %+v", i) + } + // The default state is open, which GitLab calls "opened". + if q := seen[1]; q.Get("state") != "opened" || q.Get("page") != "2" { + t.Fatalf("default query = %v", q) + } +} + +func TestListIssuesValidatesAndPropagatesErrors(t *testing.T) { + p, g, _ := seededProvider(t) + for _, req := range []gp.ListIssuesRequest{ + {Target: fixtureTarget, State: "merged"}, + {Target: fixtureTarget, Cursor: "0"}, + {Target: fixtureTarget, Cursor: "x"}, + } { + _, err := p.ListIssues(bg, req) + code(t, err, gp.CodeValidation) + } + g.forceError["GET /projects/1/issues"] = fakeResponse{403, `{"message":"403 Forbidden"}`, nil} + _, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: fixtureTarget}) + code(t, err, gp.CodeForbidden) +} + +func TestIssueListIsDeclared(t *testing.T) { + p, _, _ := seededProvider(t) + if !p.Describe().Capabilities.IssueList { + t.Fatal("issue listing must be declared") + } +} diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 3ed0efa..e26533f 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -73,6 +73,7 @@ func (p *glProvider) Describe() gp.Info { IdentityDeletion: true, IdentityDescription: true, Branches: true, + IssueList: true, BatchReview: false, HiddenMarkers: true, diff --git a/backend/spi_reads.go b/backend/spi_reads.go index 547cae0..2f59187 100644 --- a/backend/spi_reads.go +++ b/backend/spi_reads.go @@ -460,6 +460,29 @@ func (p *glProvider) ListComments(_ context.Context, req gp.ListCommentsRequest) return out, nil } +type glIssue struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` // opened | closed + WebURL string `json:"web_url"` + Author glRef `json:"author"` + Labels []string `json:"labels"` + Assignees []glRef `json:"assignees"` +} + +func (is glIssue) toIssue() gp.Issue { + state := "open" + if is.State == "closed" { + state = "closed" + } + out := gp.Issue{Number: strconv.Itoa(is.IID), Title: is.Title, Body: is.Description, State: state, Author: is.Author.Username, URL: is.WebURL, Labels: is.Labels} + for _, a := range is.Assignees { + out.Assignees = append(out.Assignees, a.Username) + } + return out +} + // GetIssue implements gp.Provider. func (p *glProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, error) { num, err := number(req.Number) @@ -470,26 +493,40 @@ func (p *glProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, if err != nil { return gp.Issue{}, err } - var is struct { - IID int `json:"iid"` - Title string `json:"title"` - Description string `json:"description"` - State string `json:"state"` - WebURL string `json:"web_url"` - Author glRef `json:"author"` - Labels []string `json:"labels"` - Assignees []glRef `json:"assignees"` - } + var is glIssue if err := api.get(proj+"/issues/"+num, &is); err != nil { return gp.Issue{}, err } - state := "open" - if is.State == "closed" { - state = "closed" + return is.toIssue(), nil +} + +// ListIssues implements gp.IssueLister. GitLab's issues endpoint returns issues +// only (merge requests have their own), newest first, so a page needs no +// filtering and the cursor follows the raw page size. +func (p *glProvider) ListIssues(_ context.Context, req gp.ListIssuesRequest) (gp.IssuePage, error) { + state := "opened" + switch req.State { + case "", "open": + case "closed", "all": + state = req.State + default: + return gp.IssuePage{}, gp.Errorf(gp.CodeValidation, "invalid issue state") } - out := gp.Issue{Number: strconv.Itoa(is.IID), Title: is.Title, Body: is.Description, State: state, Author: is.Author.Username, URL: is.WebURL, Labels: is.Labels} - for _, a := range is.Assignees { - out.Assignees = append(out.Assignees, a.Username) + pg, err := page(req.Cursor) + if err != nil { + return gp.IssuePage{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.IssuePage{}, err + } + var issues []glIssue + if err := api.get(paged(proj+"/issues?scope=all&order_by=created_at&sort=desc&state="+state, pg), &issues); err != nil { + return gp.IssuePage{}, err + } + out := gp.IssuePage{Items: make([]gp.Issue, 0, len(issues)), NextCursor: nextPage(len(issues), pg)} + for _, is := range issues { + out.Items = append(out.Items, is.toIssue()) } return out, nil }