Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/spi_assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ var (
_ gp.IdentityDeleter = (*glProvider)(nil)
_ gp.BranchManager = (*glProvider)(nil)
_ gp.IdentityDescriber = (*glProvider)(nil)
_ gp.IssueLister = (*glProvider)(nil)
)
6 changes: 6 additions & 0 deletions backend/spi_fakegitlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
91 changes: 91 additions & 0 deletions backend/spi_issues_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions backend/spi_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func (p *glProvider) Describe() gp.Info {
IdentityDeletion: true,
IdentityDescription: true,
Branches: true,
IssueList: true,

BatchReview: false,
HiddenMarkers: true,
Expand Down
69 changes: 53 additions & 16 deletions backend/spi_reads.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
Loading