diff --git a/backend/go.mod b/backend/go.mod index 3fbfeb7..f943c7f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,3 +3,5 @@ module github.com/Paca-AI/first-party/github go 1.24 require github.com/Paca-AI/plugin-sdk-go v0.4.1 + +replace github.com/Paca-AI/plugin-sdk-go => github.com/kayahk/plugin-sdk-go v0.0.0-20261102180211-0a7e5c7287f9 diff --git a/backend/go.sum b/backend/go.sum index 9925b1c..6d49666 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.4.1 h1:Fid4T5OryhJca+9dBtM09iTipli9KouY/3G5ju2Sz5Y= -github.com/Paca-AI/plugin-sdk-go v0.4.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/kayahk/plugin-sdk-go v0.0.0-20261102180211-0a7e5c7287f9 h1:OKM/oY0vR88PGi7LnZaGId2hHYroqt9BS63hmQbRS2Y= +github.com/kayahk/plugin-sdk-go v0.0.0-20261102180211-0a7e5c7287f9/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= diff --git a/backend/spi_assert.go b/backend/spi_assert.go index c2bfe8c..333f5bc 100644 --- a/backend/spi_assert.go +++ b/backend/spi_assert.go @@ -9,6 +9,7 @@ var ( _ gp.Provider = (*ghProvider)(nil) _ gp.ThreadResolver = (*ghProvider)(nil) _ gp.FileReader = (*ghProvider)(nil) + _ gp.IssueLister = (*ghProvider)(nil) _ gp.LabelManager = (*ghProvider)(nil) _ gp.ReviewerRequester = (*ghProvider)(nil) _ gp.AssigneeSetter = (*ghProvider)(nil) diff --git a/backend/spi_fakegithub_test.go b/backend/spi_fakegithub_test.go index 198fb73..b0f775e 100644 --- a/backend/spi_fakegithub_test.go +++ b/backend/spi_fakegithub_test.go @@ -478,6 +478,12 @@ func (g *fakeGitHub) repoSub(method, sub string, q url.Values, headers map[strin return resp(201, map[string]any{"ref": str(payload, "ref")}) // ── issues and comments ── + case sub == "issues" && method == "GET": + items := []map[string]any{} + if q.Get("state") != "closed" && q.Get("page") == "1" { + items = append(items, map[string]any{"number": 7, "title": "Bug report", "body": "it broke", "state": "open", "html_url": "https://github.com/acme/web/issues/7", "user": map[string]string{"login": "reader"}}) + } + return resp(200, items) case seg[0] == "issues" && len(seg) == 2 && method == "GET": n, _ := strconv.Atoi(seg[1]) if n != 7 && g.prs[n] == nil { diff --git a/backend/spi_issues_test.go b/backend/spi_issues_test.go new file mode 100644 index 0000000..8f1a398 --- /dev/null +++ b/backend/spi_issues_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "net/url" + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +func TestListIssuesFiltersPullRequestsWithoutLosingPages(t *testing.T) { + p, g, _ := seededProvider(t) + fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + u, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + if u.Path != "/repos/acme/web/issues" { + return g.fetch(method, rawURL, headers, body) + } + if method != "GET" || u.Query().Get("state") != "closed" || u.Query().Get("per_page") != "1" { + t.Fatalf("unexpected request: %s %s", method, rawURL) + } + switch u.Query().Get("page") { + case "1": + return resp(200, []map[string]any{{"number": 1, "pull_request": map[string]any{"url": "x"}}}) + case "2": + return resp(200, []map[string]any{{"number": 101, "title": "Bug", "body": "Details", "state": "closed", "html_url": "https://github.com/acme/web/issues/101", "user": map[string]string{"login": "alice"}, "labels": []map[string]string{{"name": "bug"}}, "assignees": []map[string]string{{"login": "bob"}}}}) + case "3": + return resp(200, []map[string]any{}) + default: + t.Fatalf("unexpected page: %s", rawURL) + return nil, nil + } + } + first, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: webTarget(), State: "closed"}) + if err != nil || len(first.Items) != 0 || first.NextCursor != "2" { + t.Fatalf("first page: %+v, %v", first, err) + } + second, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: webTarget(), State: "closed", Cursor: first.NextCursor}) + if err != nil || len(second.Items) != 1 || second.NextCursor != "3" { + t.Fatalf("second page: %+v, %v", second, err) + } + third, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: webTarget(), State: "closed", Cursor: second.NextCursor}) + if err != nil || len(third.Items) != 0 || third.NextCursor != "" { + t.Fatalf("third page: %+v, %v", third, err) + } + i := second.Items[0] + if i.Number != "101" || i.Title != "Bug" || i.Body != "Details" || i.State != "closed" || i.Author != "alice" || i.URL != "https://github.com/acme/web/issues/101" || len(i.Labels) != 1 || i.Labels[0] != "bug" || len(i.Assignees) != 1 || i.Assignees[0] != "bob" { + t.Fatalf("issue fields: %+v", i) + } +} + +func TestListIssuesValidatesAndPropagatesErrors(t *testing.T) { + p, g, _ := seededProvider(t) + for _, req := range []gp.ListIssuesRequest{ + {Target: webTarget(), State: "invalid"}, + {Target: webTarget(), Cursor: "-1"}, + } { + _, err := p.ListIssues(bg, req) + if codeOf(t, err) != gp.CodeValidation { + t.Fatalf("request %+v: %v", req, err) + } + } + g.forceError["GET /repos/acme/web/issues"] = fakeResponse{403, `{"message":"Resource not accessible by integration"}`, nil} + _, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: webTarget()}) + if codeOf(t, err) != gp.CodeForbidden { + t.Fatalf("GitHub error: %v", err) + } +} + +func TestListIssuesWireAndDefaultState(t *testing.T) { + p, g, _ := seededProvider(t) + fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + u, _ := url.Parse(rawURL) + if u.Path == "/repos/acme/web/issues" { + if u.Query().Get("state") != "open" { + t.Fatalf("default state: %s", rawURL) + } + return resp(200, []any{}) + } + return g.fetch(method, rawURL, headers, body) + } + if !p.Describe().Capabilities.IssueList { + t.Fatal("issue listing must be declared") + } + body, err := json.Marshal(gp.ListIssuesRequest{Target: webTarget()}) + if err != nil { + t.Fatal(err) + } + status, out := gp.Dispatch(bg, p, "list_issues", body) + if status != 200 { + t.Fatalf("wire status %d: %s", status, out) + } + var wire struct { + OK bool `json:"ok"` + Result gp.IssuePage `json:"result"` + } + if err := json.Unmarshal(out, &wire); err != nil || !wire.OK || len(wire.Result.Items) != 0 { + t.Fatalf("wire result: %s (%v)", out, err) + } +} diff --git a/backend/spi_provider.go b/backend/spi_provider.go index aeea690..9ef8b21 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -65,6 +65,7 @@ func (p *ghProvider) Describe() gp.Info { ResolveThreads: true, FileRead: true, + IssueList: true, Labels: true, ReviewerRequests: true, Assignees: true, diff --git a/backend/spi_reads.go b/backend/spi_reads.go index 9d11360..73b4fc8 100644 --- a/backend/spi_reads.go +++ b/backend/spi_reads.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/base64" + "encoding/json" "net/url" "strconv" "strings" @@ -360,6 +361,29 @@ func (p *ghProvider) ListComments(_ context.Context, req gp.ListCommentsRequest) return out, nil } +type ghIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + User ghUser `json:"user"` + Labels []struct{ Name string } `json:"labels"` + Assignees []ghUser `json:"assignees"` + PullRequest json.RawMessage `json:"pull_request"` +} + +func (is ghIssue) toIssue() gp.Issue { + out := gp.Issue{Number: strconv.Itoa(is.Number), Title: is.Title, Body: is.Body, State: is.State, Author: is.User.Login, URL: is.HTMLURL} + for _, l := range is.Labels { + out.Labels = append(out.Labels, l.Name) + } + for _, a := range is.Assignees { + out.Assignees = append(out.Assignees, a.Login) + } + return out +} + // GetIssue implements gp.Provider. func (p *ghProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, error) { num, err := number(req.Number) @@ -370,25 +394,43 @@ func (p *ghProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, if err != nil { return gp.Issue{}, err } - var is struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - State string `json:"state"` - HTMLURL string `json:"html_url"` - User ghUser `json:"user"` - Labels []struct{ Name string } `json:"labels"` - Assignees []ghUser `json:"assignees"` - } + var is ghIssue if err := api.get(repo+"/issues/"+num, &is); err != nil { return gp.Issue{}, err } - out := gp.Issue{Number: strconv.Itoa(is.Number), Title: is.Title, Body: is.Body, State: is.State, Author: is.User.Login, URL: is.HTMLURL} - for _, l := range is.Labels { - out.Labels = append(out.Labels, l.Name) + return is.toIssue(), nil +} + +// ListIssues implements gp.IssueLister. +func (p *ghProvider) ListIssues(_ context.Context, req gp.ListIssuesRequest) (gp.IssuePage, error) { + state := req.State + if state == "" { + state = "open" } - for _, a := range is.Assignees { - out.Assignees = append(out.Assignees, a.Login) + if state != "open" && state != "closed" && state != "all" { + return gp.IssuePage{}, gp.Errorf(gp.CodeValidation, "invalid issue state") + } + pg, err := page(req.Cursor) + if err != nil { + return gp.IssuePage{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.IssuePage{}, err + } + var issues []ghIssue + // shortcut: one large response can still exceed the WASM arena; upgrade the SDK transport limit handling. + if err := api.get(repo+"/issues?state="+state+"&per_page=1&page="+strconv.Itoa(pg), &issues); err != nil { + return gp.IssuePage{}, err + } + out := gp.IssuePage{Items: []gp.Issue{}} + if len(issues) > 0 { + out.NextCursor = strconv.Itoa(pg + 1) + } + for _, issue := range issues { + if len(issue.PullRequest) == 0 || string(issue.PullRequest) == "null" { + out.Items = append(out.Items, issue.toIssue()) + } } return out, nil }