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
32 changes: 32 additions & 0 deletions gitprovider/conformance/conformance.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ func testDescribe(t *testing.T, h Harness) {
t.Errorf("optional interface for %s is implemented but the capability is not declared (core would never call it)", name)
}
}
check("issue_list", c.IssueList, impl.IssueList)
check("resolve_threads", c.ResolveThreads, impl.ResolveThreads)
check("file_read", c.FileRead, impl.FileRead)
check("code_search", c.CodeSearch, impl.CodeSearch)
Expand Down Expand Up @@ -885,6 +886,37 @@ func testReads(t *testing.T, h Harness) {

// Optional reads, only when declared.
caps := p.Describe().Capabilities
if lister, ok := p.(gp.IssueLister); ok && caps.IssueList {
seen, cursors := map[string]bool{}, map[string]bool{}
cursor := ""
for {
page, err := lister.ListIssues(ctx(), gp.ListIssuesRequest{Target: tg, State: "all", Cursor: cursor})
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
for _, issue := range page.Items {
if issue.Number == "" || seen[issue.Number] || issue.Title == "" || issue.URL == "" || (issue.State != "open" && issue.State != "closed") {
t.Errorf("invalid or duplicate issue: %+v", issue)
}
seen[issue.Number] = true
}
if page.NextCursor == "" {
break
}
if cursors[page.NextCursor] || len(cursors) >= 100 {
t.Fatal("ListIssues pagination did not terminate")
}
cursors[page.NextCursor] = true
cursor = page.NextCursor
}
if f.IssueNumber != "" && !seen[f.IssueNumber] {
t.Errorf("ListIssues did not return fixture issue %q", f.IssueNumber)
}
if f.InvisibleRepo.FullPath != "" {
_, err := lister.ListIssues(ctx(), gp.ListIssuesRequest{Target: gp.Target{Identity: f.Identity, Repo: f.InvisibleRepo}})
mustCode(t, f, err, gp.CodeNotFound, gp.CodeForbidden)
}
}
if fr, ok := p.(gp.FileReader); ok && caps.FileRead && f.File.Path != "" {
file, err := fr.GetFile(ctx(), gp.FileRequest{Target: tg, Ref: f.File.Ref, Path: f.File.Path})
if err != nil || len(file.Content) == 0 {
Expand Down
76 changes: 76 additions & 0 deletions gitprovider/fakeprovider/issue_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package fakeprovider

import (
"context"
"errors"
"testing"

gp "github.com/Paca-AI/plugin-sdk-go/gitprovider"
)

func TestListIssuesStatesAndPages(t *testing.T) {
p := New()
target := gp.Target{Identity: gp.IdentityRef{ID: IdentityID}, Repo: gp.RepoRef{FullPath: RepoPath}}
r := p.repos[RepoPath]
r.issues["8"] = &gp.Issue{Number: "8", State: "closed"}
r.issues["9"] = &gp.Issue{Number: "9", State: "open"}
r.issues["10"] = &gp.Issue{Number: "10", State: "open"}
for _, tc := range []struct {
state string
want map[string]bool
}{
{"", map[string]bool{"7": true, "9": true, "10": true}},
{"open", map[string]bool{"7": true, "9": true, "10": true}},
{"closed", map[string]bool{"8": true}},
{"all", map[string]bool{"7": true, "8": true, "9": true, "10": true}},
} {
t.Run(tc.state, func(t *testing.T) {
seen := map[string]bool{}
cursor := ""
for i := 0; i < 10; i++ {
page, err := p.ListIssues(context.Background(), gp.ListIssuesRequest{Target: target, State: tc.state, Cursor: cursor})
if err != nil {
t.Fatal(err)
}
for _, issue := range page.Items {
if !tc.want[issue.Number] || seen[issue.Number] {
t.Fatalf("unexpected or duplicate issue %q", issue.Number)
}
seen[issue.Number] = true
}
if page.NextCursor == "" {
break
}
if page.NextCursor == cursor {
t.Fatal("cursor did not advance")
}
cursor = page.NextCursor
if i == 9 {
t.Fatal("pagination did not terminate")
}
}
if len(seen) != len(tc.want) {
t.Fatalf("got %v, want %v", seen, tc.want)
}
})
}
}

func TestListIssuesRejectsInvalidInput(t *testing.T) {
p := New()
target := gp.Target{Identity: gp.IdentityRef{ID: IdentityID}, Repo: gp.RepoRef{FullPath: RepoPath}}
for _, req := range []gp.ListIssuesRequest{
{Target: target, State: "merged"},
{Target: target, Cursor: "-1"},
{Target: target, Cursor: "garbage"},
} {
_, err := p.ListIssues(context.Background(), req)
if !errors.Is(err, gp.Errorf(gp.CodeValidation, "")) {
t.Errorf("request %+v: got %v, want validation", req, err)
}
}
_, err := p.ListIssues(context.Background(), gp.ListIssuesRequest{Target: gp.Target{}})
if !errors.Is(err, gp.Errorf(gp.CodeNotFound, "")) {
t.Errorf("bad target: got %v, want not_found", err)
}
}
20 changes: 20 additions & 0 deletions gitprovider/fakeprovider/mutants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ func TestMutant_DishonestCapability(t *testing.T) {
run(t, func() gp.Provider { return dishonest{fakeprovider.New()} })
}

type noIssueList struct{ *fakeprovider.Full }

func (n noIssueList) Describe() gp.Info {
i := n.Full.Describe()
i.Capabilities.IssueList = false
return i
}
func TestMutant_DishonestIssueList(t *testing.T) {
run(t, func() gp.Provider { return noIssueList{fakeprovider.New()} })
}

type emptyIssueList struct{ *fakeprovider.Full }

func (n emptyIssueList) ListIssues(context.Context, gp.ListIssuesRequest) (gp.IssuePage, error) {
return gp.IssuePage{Items: []gp.Issue{}}, nil
}
func TestMutant_EmptyIssueList(t *testing.T) {
run(t, func() gp.Provider { return emptyIssueList{fakeprovider.New()} })
}

type noExpiry struct{ *fakeprovider.Full }

func (n noExpiry) MintCredential(ctx context.Context, r gp.MintRequest) (gp.Credential, error) {
Expand Down
48 changes: 46 additions & 2 deletions gitprovider/fakeprovider/variants.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package fakeprovider
import (
"context"
"net/url"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -33,8 +34,8 @@ func New() *Full {
IdentityKinds: []gp.IdentityKind{gp.IdentityApp, gp.IdentityBotUser}, CredentialModel: gp.CredentialEphemeral,
WebhookScope: gp.WebhookApp, StatusKind: gp.StatusCheckRun,
Triggers: gp.AllTriggerKinds,
MultipleIdentities: true,
ResolveThreads: true, FileRead: true, CodeSearch: true, Labels: true, ReviewerRequests: true, Assignees: true,
MultipleIdentities: true, IssueList: true,
ResolveThreads: true, FileRead: true, CodeSearch: true, Labels: true, ReviewerRequests: true, Assignees: true,
Merge: true, RerunChecks: true, Reactions: true, Branches: true, WebhookManagement: true, IdentityDeletion: true, AccessGrants: true,
BatchReview: true, IdempotentWrites: true, HiddenMarkers: true, ForkChangeRequests: true, DraftChangeRequests: true,
CIConfigGlobs: []string{".fake-ci/**"},
Expand Down Expand Up @@ -113,6 +114,49 @@ func (f *Full) CreateBranch(_ context.Context, req gp.CreateBranchRequest) (gp.B
})
}

// ListIssues implements gp.IssueLister.
func (f *Full) ListIssues(_ context.Context, req gp.ListIssuesRequest) (gp.IssuePage, error) {
f.mu.Lock()
defer f.mu.Unlock()
r, err := f.repo(req.Target)
if err != nil {
return gp.IssuePage{}, err
}
state := req.State
if state == "" {
state = "open"
}
if state != "open" && state != "closed" && state != "all" {
return gp.IssuePage{}, gp.Errorf(gp.CodeValidation, "invalid issue state")
}
start := 0
if req.Cursor != "" {
var err error
start, err = strconv.Atoi(req.Cursor)
if err != nil || start < 0 {
return gp.IssuePage{}, gp.Errorf(gp.CodeValidation, "invalid cursor")
}
}
items := []gp.Issue{}
for _, issue := range r.issues {
if state == "all" || issue.State == state {
items = append(items, *issue)
}
}
sort.Slice(items, func(i, j int) bool { return items[i].Number < items[j].Number })
page := gp.IssuePage{Items: []gp.Issue{}}
if start < len(items) {
end := start + 2
if end < len(items) {
page.NextCursor = strconv.Itoa(end)
} else {
end = len(items)
}
page.Items = items[start:end]
}
return page, nil
}

// ResolveThread implements gp.ThreadResolver.
func (f *Full) ResolveThread(_ context.Context, req gp.ResolveThreadRequest) (gp.Empty, error) {
f.mu.Lock()
Expand Down
12 changes: 7 additions & 5 deletions gitprovider/gitprovider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package gitprovider_test

import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -41,9 +41,11 @@ func TestAsError_NeverLeaksInternals(t *testing.T) {

func TestOperationsTable(t *testing.T) {
// Every optional operation names a capability that exists in Capabilities.
raw, _ := json.Marshal(gp.Capabilities{})
var caps map[string]any
_ = json.Unmarshal(raw, &caps)
caps := map[string]bool{}
typ := reflect.TypeOf(gp.Capabilities{})
for i := 0; i < typ.NumField(); i++ {
caps[strings.Split(typ.Field(i).Tag.Get("json"), ",")[0]] = true
}
seen := map[string]bool{}
for _, op := range gp.Operations() {
if seen[op.Name] {
Expand All @@ -58,7 +60,7 @@ func TestOperationsTable(t *testing.T) {
}
// Every capability that gates an operation is covered by the table, so a new
// optional interface cannot be added without a wire operation.
for _, flag := range []string{"resolve_threads", "file_read", "code_search", "labels", "reviewer_requests", "assignees", "merge", "rerun_checks", "reactions", "webhook_management", "identity_deletion", "access_grants"} {
for _, flag := range []string{"issue_list", "resolve_threads", "file_read", "code_search", "labels", "reviewer_requests", "assignees", "merge", "rerun_checks", "reactions", "webhook_management", "identity_deletion", "access_grants"} {
found := false
for _, op := range gp.Operations() {
if op.Capability == flag {
Expand Down
137 changes: 137 additions & 0 deletions gitprovider/issue_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package gitprovider_test

import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"

gp "github.com/Paca-AI/plugin-sdk-go/gitprovider"
"github.com/Paca-AI/plugin-sdk-go/gitprovider/fakeprovider"
)

func TestListIssuesContract(t *testing.T) {
if !gp.Implemented(fakeprovider.New()).IssueList || gp.Implemented(fakeprovider.NewMinimal()).IssueList {
t.Fatal("IssueList implementation detection is wrong")
}
operations := gp.Operations()
found := false
for _, op := range operations {
if op.Name == "list_issues" && op.Capability == "issue_list" {
found = true
}
}
if !found {
t.Fatal("list_issues must be gated by issue_list")
}
for name, typ := range map[string]reflect.Type{"ListIssuesRequest": reflect.TypeOf(gp.ListIssuesRequest{}), "IssuePage": reflect.TypeOf(gp.IssuePage{})} {
if gp.WireTypes()[name] != typ {
t.Errorf("WireTypes missing %s", name)
}
}
for _, tc := range []struct {
value any
want string
}{
{gp.ListIssuesRequest{}, `{"target":{"identity":{"id":""},"repo":{"host":"","full_path":""}}}`},
{gp.ListIssuesRequest{State: "closed", Cursor: "next"}, `{"target":{"identity":{"id":""},"repo":{"host":"","full_path":""}},"state":"closed","cursor":"next"}`},
{gp.IssuePage{Items: []gp.Issue{}}, `{"items":[]}`},
{gp.IssuePage{Items: []gp.Issue{}, NextCursor: "next"}, `{"items":[],"next_cursor":"next"}`},
} {
got, err := json.Marshal(tc.value)
if err != nil || string(got) != tc.want {
t.Errorf("marshal: %s, %v; want %s", got, err, tc.want)
}
}
}

type missingIssueLister struct{ gp.Provider }

func (p missingIssueLister) Describe() gp.Info {
i := p.Provider.Describe()
i.Capabilities.IssueList = true
return i
}

type hiddenIssueLister struct{ *fakeprovider.Full }

func (p hiddenIssueLister) Describe() gp.Info {
i := p.Full.Describe()
i.Capabilities.IssueList = false
return i
}

func TestListIssuesWireGuards(t *testing.T) {
for _, p := range []gp.Provider{fakeprovider.NewMinimal(), missingIssueLister{fakeprovider.NewMinimal()}, hiddenIssueLister{fakeprovider.New()}} {
status, raw := gp.Dispatch(context.Background(), p, "list_issues", []byte(`{}`))
var env gp.Envelope
if err := json.Unmarshal(raw, &env); err != nil || status != 501 || env.Error == nil || !errors.Is(env.Error, gp.ErrUnsupported) {
t.Errorf("unsupported: status=%d body=%s err=%v", status, raw, err)
}
}
for _, body := range [][]byte{nil, []byte(`{`)} {
status, raw := gp.Dispatch(context.Background(), fakeprovider.New(), "list_issues", body)
var env gp.Envelope
if err := json.Unmarshal(raw, &env); err != nil || status != 400 || env.Error == nil || env.Error.Code != gp.CodeValidation {
t.Errorf("invalid body: status=%d body=%s err=%v", status, raw, err)
}
}
}

func TestListIssuesDispatchFiltersAndValidates(t *testing.T) {
f := fakeprovider.Fixtures()
target := gp.Target{Identity: f.Identity, Repo: f.Repo}
for _, tc := range []struct {
state, cursor string
status int
code gp.ErrorCode
items int
}{
{"closed", "", 200, "", 0},
{"all", "", 200, "", 1},
{"all", "2", 200, "", 0},
{"invalid", "", 400, gp.CodeValidation, 0},
{"all", "bad", 400, gp.CodeValidation, 0},
} {
body, _ := json.Marshal(gp.ListIssuesRequest{Target: target, State: tc.state, Cursor: tc.cursor})
status, raw := gp.Dispatch(context.Background(), fakeprovider.New(), "list_issues", body)
var env gp.Envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatal(err)
}
if status != tc.status {
t.Fatalf("%+v: status=%d body=%s", tc, status, raw)
}
if tc.code != "" {
if env.Error == nil || env.Error.Code != tc.code {
t.Fatalf("%+v: body=%s", tc, raw)
}
continue
}
var page gp.IssuePage
if err := json.Unmarshal(env.Result, &page); err != nil || !env.OK || len(page.Items) != tc.items {
t.Fatalf("%+v: body=%s err=%v", tc, raw, err)
}
}
}

func TestListIssuesDispatch(t *testing.T) {
p := fakeprovider.New()
f := fakeprovider.Harness(false).Fixtures
body, err := json.Marshal(map[string]any{"target": gp.Target{Identity: f.Identity, Repo: f.Repo}})
if err != nil {
t.Fatal(err)
}
status, raw := gp.Dispatch(context.Background(), p, "list_issues", body)
var env gp.Envelope
if err := json.Unmarshal(raw, &env); err != nil || status != 200 || !env.OK {
t.Fatalf("list_issues: status=%d body=%s err=%v", status, raw, err)
}
var page struct {
Items []gp.Issue `json:"items"`
}
if err := json.Unmarshal(env.Result, &page); err != nil || len(page.Items) != 1 || page.Items[0].Number != f.IssueNumber {
t.Fatalf("issue page: %+v err=%v", page, err)
}
}
Loading
Loading