Skip to content

Commit ee8b454

Browse files
authored
fix(linear): follow GraphQL cursors past first:50 + add updatedAt delta filter (#89)
ListIssues silently truncated any project with more than 50 issues since it never followed pageInfo.hasNextPage/endCursor. Loop the cursor until exhausted (page size 100) and add ListIssuesSince for updatedAt-filtered delta polling with the same cursor-following, needed for SaaS board-sync watermark polling.
1 parent fd838f6 commit ee8b454

2 files changed

Lines changed: 303 additions & 22 deletions

File tree

sdk/integrations/linear/client.go

Lines changed: 125 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -260,17 +260,29 @@ type ListIssuesOptions struct {
260260
States []string
261261
}
262262

263-
// ListIssues fetches issues matching the filter criteria.
263+
// listIssuesPageSize is the GraphQL page size used when cursor-following
264+
// issue list queries. Linear's API max is 250; we stay well under it.
265+
const listIssuesPageSize = 100
266+
267+
// pageInfo is the GraphQL Relay-style pagination cursor.
268+
type pageInfo struct {
269+
HasNextPage bool `json:"hasNextPage"`
270+
EndCursor string `json:"endCursor"`
271+
}
272+
273+
// ListIssues fetches all issues matching the filter criteria, following
274+
// pagination cursors until exhausted.
264275
func (c *Client) ListIssues(ctx context.Context, opts *ListIssuesOptions) ([]*Issue, error) {
265276
query := `
266-
query ListIssues($teamId: String!, $label: String!, $states: [String!]) {
277+
query ListIssues($teamId: String!, $label: String!, $states: [String!], $first: Int!, $after: String) {
267278
issues(
268279
filter: {
269280
team: { key: { eq: $teamId } }
270281
labels: { name: { eq: $label } }
271282
state: { type: { in: $states } }
272283
}
273-
first: 50
284+
first: $first
285+
after: $after
274286
orderBy: createdAt
275287
) {
276288
nodes {
@@ -287,6 +299,7 @@ func (c *Client) ListIssues(ctx context.Context, opts *ListIssuesOptions) ([]*Is
287299
createdAt
288300
updatedAt
289301
}
302+
pageInfo { hasNextPage endCursor }
290303
}
291304
}
292305
`
@@ -296,31 +309,121 @@ func (c *Client) ListIssues(ctx context.Context, opts *ListIssuesOptions) ([]*Is
296309
states = []string{"backlog", "unstarted", "started"}
297310
}
298311

299-
variables := map[string]interface{}{
300-
"teamId": opts.TeamID,
301-
"label": opts.Label,
302-
"states": states,
303-
}
312+
var issues []*Issue
313+
after := ""
314+
for {
315+
variables := map[string]interface{}{
316+
"teamId": opts.TeamID,
317+
"label": opts.Label,
318+
"states": states,
319+
"first": listIssuesPageSize,
320+
}
321+
if after != "" {
322+
variables["after"] = after
323+
}
304324

305-
var result struct {
306-
Issues struct {
307-
Nodes []*issueListItem `json:"nodes"`
308-
} `json:"issues"`
309-
}
325+
var result struct {
326+
Issues struct {
327+
Nodes []*issueListItem `json:"nodes"`
328+
PageInfo pageInfo `json:"pageInfo"`
329+
} `json:"issues"`
330+
}
310331

311-
if err := c.Execute(ctx, query, variables, &result); err != nil {
312-
return nil, err
332+
if err := c.Execute(ctx, query, variables, &result); err != nil {
333+
return nil, err
334+
}
335+
336+
for _, resp := range result.Issues.Nodes {
337+
issue := resp.toIssue()
338+
if len(opts.ProjectIDs) > 0 {
339+
if issue.Project == nil || !containsString(opts.ProjectIDs, issue.Project.ID) {
340+
continue
341+
}
342+
}
343+
issues = append(issues, issue)
344+
}
345+
346+
if !result.Issues.PageInfo.HasNextPage {
347+
break
348+
}
349+
after = result.Issues.PageInfo.EndCursor
313350
}
314351

315-
issues := make([]*Issue, 0, len(result.Issues.Nodes))
316-
for _, resp := range result.Issues.Nodes {
317-
issue := resp.toIssue()
318-
if len(opts.ProjectIDs) > 0 {
319-
if issue.Project == nil || !containsString(opts.ProjectIDs, issue.Project.ID) {
320-
continue
352+
return issues, nil
353+
}
354+
355+
// ListIssuesSinceOptions configures a delta issue listing.
356+
type ListIssuesSinceOptions struct {
357+
TeamID string
358+
Since time.Time
359+
}
360+
361+
// ListIssuesSince fetches all issues whose updatedAt is strictly greater than
362+
// since, ordered by updatedAt, following pagination cursors until exhausted.
363+
// It is intended for delta polling with a watermark cursor rather than a full
364+
// refetch on every poll.
365+
func (c *Client) ListIssuesSince(ctx context.Context, opts *ListIssuesSinceOptions) ([]*Issue, error) {
366+
query := `
367+
query ListIssuesSince($teamId: String!, $since: DateTimeOrDuration!, $first: Int!, $after: String) {
368+
issues(
369+
filter: {
370+
team: { key: { eq: $teamId } }
371+
updatedAt: { gt: $since }
372+
}
373+
first: $first
374+
after: $after
375+
orderBy: updatedAt
376+
) {
377+
nodes {
378+
id
379+
identifier
380+
title
381+
description
382+
priority
383+
state { id name type }
384+
labels { nodes { id name } }
385+
assignee { id name email }
386+
project { id name }
387+
team { id name key }
388+
createdAt
389+
updatedAt
390+
}
391+
pageInfo { hasNextPage endCursor }
321392
}
322393
}
323-
issues = append(issues, issue)
394+
`
395+
396+
var issues []*Issue
397+
after := ""
398+
for {
399+
variables := map[string]interface{}{
400+
"teamId": opts.TeamID,
401+
"since": opts.Since.UTC().Format(time.RFC3339),
402+
"first": listIssuesPageSize,
403+
}
404+
if after != "" {
405+
variables["after"] = after
406+
}
407+
408+
var result struct {
409+
Issues struct {
410+
Nodes []*issueListItem `json:"nodes"`
411+
PageInfo pageInfo `json:"pageInfo"`
412+
} `json:"issues"`
413+
}
414+
415+
if err := c.Execute(ctx, query, variables, &result); err != nil {
416+
return nil, err
417+
}
418+
419+
for _, resp := range result.Issues.Nodes {
420+
issues = append(issues, resp.toIssue())
421+
}
422+
423+
if !result.Issues.PageInfo.HasNextPage {
424+
break
425+
}
426+
after = result.Issues.PageInfo.EndCursor
324427
}
325428

326429
return issues, nil

sdk/integrations/linear/client_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,184 @@ func TestGetTeamDoneStateID(t *testing.T) {
996996
}
997997
}
998998

999+
func TestListIssues_FollowsCursorPastFirstPage(t *testing.T) {
1000+
pageOf := func(ids []string, hasNext bool, endCursor string) string {
1001+
nodes := ""
1002+
for _, id := range ids {
1003+
nodes += fmt.Sprintf(`{
1004+
"id": "%s", "identifier": "%s", "title": "t", "description": "",
1005+
"priority": 0,
1006+
"state": {"id": "s", "name": "Todo", "type": "unstarted"},
1007+
"labels": {"nodes": []},
1008+
"assignee": null, "project": null,
1009+
"team": {"id": "team-1", "name": "Eng", "key": "ENG"},
1010+
"createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z"
1011+
},`, id, id)
1012+
}
1013+
nodes = nodes[:len(nodes)-1] // trim trailing comma
1014+
return fmt.Sprintf(`{"issues": {"nodes": [%s], "pageInfo": {"hasNextPage": %v, "endCursor": "%s"}}}`, nodes, hasNext, endCursor)
1015+
}
1016+
1017+
var calls int
1018+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1019+
var reqBody GraphQLRequest
1020+
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
1021+
t.Fatalf("failed to decode request body: %v", err)
1022+
}
1023+
calls++
1024+
1025+
after, _ := reqBody.Variables["after"].(string)
1026+
var body string
1027+
switch after {
1028+
case "":
1029+
body = pageOf([]string{"i1", "i2"}, true, "cursor-1")
1030+
case "cursor-1":
1031+
body = pageOf([]string{"i3", "i4"}, true, "cursor-2")
1032+
case "cursor-2":
1033+
body = pageOf([]string{"i5"}, false, "")
1034+
default:
1035+
t.Fatalf("unexpected after cursor: %q", after)
1036+
}
1037+
1038+
w.Header().Set("Content-Type", "application/json")
1039+
_ = json.NewEncoder(w).Encode(GraphQLResponse{Data: json.RawMessage(body)})
1040+
}))
1041+
defer server.Close()
1042+
1043+
client := NewClientWithBaseURL(testutil.FakeLinearToken, server.URL)
1044+
1045+
issues, err := client.ListIssues(context.Background(), &ListIssuesOptions{TeamID: "ENG", Label: "bug"})
1046+
if err != nil {
1047+
t.Fatalf("ListIssues failed: %v", err)
1048+
}
1049+
1050+
if calls != 3 {
1051+
t.Errorf("expected 3 paginated requests, got %d", calls)
1052+
}
1053+
if len(issues) != 5 {
1054+
t.Fatalf("expected 5 issues across 3 pages, got %d", len(issues))
1055+
}
1056+
want := []string{"i1", "i2", "i3", "i4", "i5"}
1057+
for i, id := range want {
1058+
if issues[i].ID != id {
1059+
t.Errorf("issues[%d].ID = %s, want %s", i, issues[i].ID, id)
1060+
}
1061+
}
1062+
}
1063+
1064+
func TestListIssues_SinglePage_NoBehaviorChange(t *testing.T) {
1065+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1066+
var reqBody GraphQLRequest
1067+
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
1068+
t.Fatalf("failed to decode request body: %v", err)
1069+
}
1070+
if after, ok := reqBody.Variables["after"]; ok && after != "" {
1071+
t.Fatalf("unexpected after cursor on first request: %v", after)
1072+
}
1073+
1074+
resp := GraphQLResponse{Data: json.RawMessage(`{
1075+
"issues": {
1076+
"nodes": [
1077+
{
1078+
"id": "issue-1", "identifier": "ENG-1", "title": "t", "description": "",
1079+
"priority": 0,
1080+
"state": {"id": "s", "name": "Todo", "type": "unstarted"},
1081+
"labels": {"nodes": []},
1082+
"assignee": null, "project": null,
1083+
"team": {"id": "team-1", "name": "Eng", "key": "ENG"},
1084+
"createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z"
1085+
}
1086+
],
1087+
"pageInfo": {"hasNextPage": false, "endCursor": ""}
1088+
}
1089+
}`)}
1090+
w.Header().Set("Content-Type", "application/json")
1091+
_ = json.NewEncoder(w).Encode(resp)
1092+
}))
1093+
defer server.Close()
1094+
1095+
client := NewClientWithBaseURL(testutil.FakeLinearToken, server.URL)
1096+
1097+
issues, err := client.ListIssues(context.Background(), &ListIssuesOptions{TeamID: "ENG", Label: "bug"})
1098+
if err != nil {
1099+
t.Fatalf("ListIssues failed: %v", err)
1100+
}
1101+
if len(issues) != 1 {
1102+
t.Fatalf("expected 1 issue, got %d", len(issues))
1103+
}
1104+
if issues[0].ID != "issue-1" {
1105+
t.Errorf("issues[0].ID = %s, want issue-1", issues[0].ID)
1106+
}
1107+
}
1108+
1109+
func TestListIssuesSince_FiltersAndFollowsCursor(t *testing.T) {
1110+
since := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
1111+
1112+
var calls int
1113+
var sawSinceFilter bool
1114+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1115+
var reqBody GraphQLRequest
1116+
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
1117+
t.Fatalf("failed to decode request body: %v", err)
1118+
}
1119+
calls++
1120+
1121+
if sinceVar, _ := reqBody.Variables["since"].(string); sinceVar == since.Format(time.RFC3339) {
1122+
sawSinceFilter = true
1123+
}
1124+
if !contains(reqBody.Query, "updatedAt: { gt: $since }") {
1125+
t.Errorf("query should filter on updatedAt gt $since, got: %s", reqBody.Query)
1126+
}
1127+
if !contains(reqBody.Query, "orderBy: updatedAt") {
1128+
t.Errorf("query should order by updatedAt, got: %s", reqBody.Query)
1129+
}
1130+
1131+
after, _ := reqBody.Variables["after"].(string)
1132+
var body string
1133+
if after == "" {
1134+
body = `{"issues": {"nodes": [
1135+
{"id": "i1", "identifier": "ENG-1", "title": "t", "description": "",
1136+
"priority": 0, "state": {"id": "s", "name": "Todo", "type": "unstarted"},
1137+
"labels": {"nodes": []}, "assignee": null, "project": null,
1138+
"team": {"id": "team-1", "name": "Eng", "key": "ENG"},
1139+
"createdAt": "2024-06-02T00:00:00Z", "updatedAt": "2024-06-02T00:00:00Z"}
1140+
], "pageInfo": {"hasNextPage": true, "endCursor": "cursor-1"}}}`
1141+
} else {
1142+
body = `{"issues": {"nodes": [
1143+
{"id": "i2", "identifier": "ENG-2", "title": "t", "description": "",
1144+
"priority": 0, "state": {"id": "s", "name": "Todo", "type": "unstarted"},
1145+
"labels": {"nodes": []}, "assignee": null, "project": null,
1146+
"team": {"id": "team-1", "name": "Eng", "key": "ENG"},
1147+
"createdAt": "2024-06-03T00:00:00Z", "updatedAt": "2024-06-03T00:00:00Z"}
1148+
], "pageInfo": {"hasNextPage": false, "endCursor": ""}}}`
1149+
}
1150+
1151+
w.Header().Set("Content-Type", "application/json")
1152+
_ = json.NewEncoder(w).Encode(GraphQLResponse{Data: json.RawMessage(body)})
1153+
}))
1154+
defer server.Close()
1155+
1156+
client := NewClientWithBaseURL(testutil.FakeLinearToken, server.URL)
1157+
1158+
issues, err := client.ListIssuesSince(context.Background(), &ListIssuesSinceOptions{TeamID: "ENG", Since: since})
1159+
if err != nil {
1160+
t.Fatalf("ListIssuesSince failed: %v", err)
1161+
}
1162+
1163+
if calls != 2 {
1164+
t.Errorf("expected 2 paginated requests, got %d", calls)
1165+
}
1166+
if !sawSinceFilter {
1167+
t.Error("expected since variable to be sent as RFC3339 formatted string")
1168+
}
1169+
if len(issues) != 2 {
1170+
t.Fatalf("expected 2 issues across 2 pages, got %d", len(issues))
1171+
}
1172+
if issues[0].ID != "i1" || issues[1].ID != "i2" {
1173+
t.Errorf("issues = %v, want [i1, i2] in order", issues)
1174+
}
1175+
}
1176+
9991177
func TestGetTeamDoneStateID_Cache(t *testing.T) {
10001178
callCount := 0
10011179
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)