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
2 changes: 2 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions backend/spi_assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions backend/spi_fakegithub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions backend/spi_issues_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions backend/spi_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func (p *ghProvider) Describe() gp.Info {

ResolveThreads: true,
FileRead: true,
IssueList: true,
Labels: true,
ReviewerRequests: true,
Assignees: true,
Expand Down
72 changes: 57 additions & 15 deletions backend/spi_reads.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"net/url"
"strconv"
"strings"
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
Loading