Skip to content

Commit 50dab1a

Browse files
authored
Merge pull request #53 from czottmann/feat/cursor-pagination
feat(pagination): add cursor-based pagination to all list commands
2 parents 64dfac1 + 969307a commit 50dab1a

38 files changed

Lines changed: 1467 additions & 127 deletions

USAGE.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ arguments:
5858
<title> string
5959
6060
list options:
61-
--query <text> filter by text search
62-
--limit <n> max results (default: 50)
61+
--query <text> filter by text search
62+
--limit <n> max results (default: 50)
63+
--after <cursor> cursor for next page
6364
6465
create options:
6566
--description <text> issue body
@@ -129,7 +130,9 @@ commands:
129130
list [options] list available labels
130131
131132
list options:
132-
--team <team> filter by team (key, name, or UUID)
133+
--team <team> filter by team (key, name, or UUID)
134+
--limit <n> max results (default: 50)
135+
--after <cursor> cursor for next page
133136
134137
see also: issues create --labels, issues update --labels
135138
@@ -144,7 +147,8 @@ commands:
144147
list [options] list projects
145148
146149
list options:
147-
--limit <n> max results (default: 100)
150+
--limit <n> max results (default: 100)
151+
--after <cursor> cursor for next page
148152
149153
see also: milestones list --project, documents list --project
150154
@@ -163,9 +167,11 @@ arguments:
163167
<cycle> cycle identifier (UUID or name)
164168
165169
list options:
166-
--team <team> filter by team (key, name, or UUID)
167-
--active only show active cycles
168-
--window <n> active cycle +/- n neighbors (requires --team)
170+
--team <team> filter by team (key, name, or UUID)
171+
--active only show active cycles
172+
--window <n> active cycle +/- n neighbors (requires --team)
173+
--limit <n> max results (default: 50)
174+
--after <cursor> cursor for next page
169175
170176
read options:
171177
--team <team> scope name lookup to team
@@ -193,6 +199,7 @@ arguments:
193199
list options:
194200
--project <project> target project (required)
195201
--limit <n> max results (default: 50)
202+
--after <cursor> cursor for next page
196203
197204
read options:
198205
--project <project> scope name lookup to project
@@ -233,6 +240,7 @@ list options:
233240
--project <project> filter by project name or ID
234241
--issue <issue> filter by issue (shows documents attached to the issue)
235242
--limit <n> max results (default: 50)
243+
--after <cursor> cursor for next page
236244
237245
create options:
238246
--title <title> document title (required)
@@ -279,7 +287,11 @@ a team is a group of users that owns issues, cycles, statuses, and
279287
labels. teams are identified by a short key (e.g. ENG), name, or UUID.
280288
281289
commands:
282-
list list all teams
290+
list [options] list all teams
291+
292+
list options:
293+
--limit <n> max results (default: 50)
294+
--after <cursor> cursor for next page
283295
284296
---
285297
@@ -292,4 +304,6 @@ commands:
292304
list [options] list workspace members
293305
294306
list options:
295-
--active only show active users
307+
--active only show active users
308+
--limit <n> max results (default: 50)
309+
--after <cursor> cursor for next page

graphql/queries/cycles.graphql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,15 @@ fragment CycleWithIssuesFields on Cycle {
5858
# Variables:
5959
# $first: Maximum number of cycles to return (default: 50)
6060
# $filter: Optional CycleFilter for team/status filtering
61-
query GetCycles($first: Int = 50, $filter: CycleFilter) {
62-
cycles(first: $first, filter: $filter) {
61+
query GetCycles($first: Int = 50, $after: String, $filter: CycleFilter) {
62+
cycles(first: $first, after: $after, filter: $filter) {
6363
nodes {
6464
...CycleFields
6565
}
66+
pageInfo {
67+
hasNextPage
68+
endCursor
69+
}
6670
}
6771
}
6872

graphql/queries/documents.graphql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@ query GetDocument($id: String!) {
3535
# List documents with optional filtering
3636
#
3737
# Fetches a list of documents with optional filtering criteria.
38-
query ListDocuments($first: Int!, $filter: DocumentFilter) {
39-
documents(first: $first, filter: $filter) {
38+
query ListDocuments($first: Int!, $after: String, $filter: DocumentFilter) {
39+
documents(first: $first, after: $after, filter: $filter) {
4040
nodes {
4141
...DocumentFields
4242
}
43+
pageInfo {
44+
hasNextPage
45+
endCursor
46+
}
4347
}
4448
}

graphql/queries/issues.graphql

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,20 @@ fragment CompleteIssueSearchFields on IssueSearchResult {
191191
# Fetches paginated issues excluding completed ones,
192192
# ordered by most recently updated. Includes all relationships
193193
# for comprehensive issue data.
194-
query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {
194+
query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) {
195195
issues(
196196
first: $first
197+
after: $after
197198
orderBy: $orderBy
198199
filter: { state: { type: { neq: "completed" } } }
199200
) {
200201
nodes {
201202
...CompleteIssueFields
202203
}
204+
pageInfo {
205+
hasNextPage
206+
endCursor
207+
}
203208
}
204209
}
205210

@@ -243,11 +248,20 @@ query GetIssueTeam($issueId: String!) {
243248
#
244249
# Provides full-text search across Linear issues with complete
245250
# relationship data for each match.
246-
query SearchIssues($term: String!, $first: Int!) {
247-
searchIssues(term: $term, first: $first, includeArchived: false) {
251+
query SearchIssues($term: String!, $first: Int!, $after: String) {
252+
searchIssues(
253+
term: $term
254+
first: $first
255+
after: $after
256+
includeArchived: false
257+
) {
248258
nodes {
249259
...CompleteIssueSearchFields
250260
}
261+
pageInfo {
262+
hasNextPage
263+
endCursor
264+
}
251265
}
252266
}
253267

@@ -257,18 +271,24 @@ query SearchIssues($term: String!, $first: Int!) {
257271
# Used by the advanced search functionality with multiple criteria.
258272
query FilteredSearchIssues(
259273
$first: Int!
274+
$after: String
260275
$filter: IssueFilter
261276
$orderBy: PaginationOrderBy
262277
) {
263278
issues(
264279
first: $first
280+
after: $after
265281
filter: $filter
266282
orderBy: $orderBy
267283
includeArchived: false
268284
) {
269285
nodes {
270286
...CompleteIssueFields
271287
}
288+
pageInfo {
289+
hasNextPage
290+
endCursor
291+
}
272292
}
273293
}
274294

graphql/queries/labels.graphql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ fragment LabelFields on IssueLabel {
3131
# Variables:
3232
# $first: Maximum number of labels to return (default: 50)
3333
# $filter: Optional filter (e.g., { team: { id: { eq: "team-uuid" } } })
34-
query GetLabels($first: Int = 50, $filter: IssueLabelFilter) {
35-
issueLabels(first: $first, filter: $filter) {
34+
query GetLabels($first: Int = 50, $after: String, $filter: IssueLabelFilter) {
35+
issueLabels(first: $first, after: $after, filter: $filter) {
3636
nodes {
3737
...LabelFields
3838
}
39+
pageInfo {
40+
hasNextPage
41+
endCursor
42+
}
3943
}
4044
}

graphql/queries/project-milestones.graphql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
# List project milestones in a project
99
#
1010
# Fetches a list of project milestones for a given project.
11-
query ListProjectMilestones($projectId: String!, $first: Int!) {
11+
query ListProjectMilestones($projectId: String!, $first: Int!, $after: String) {
1212
project(id: $projectId) {
1313
id
1414
name
15-
projectMilestones(first: $first) {
15+
projectMilestones(first: $first, after: $after) {
1616
nodes {
1717
id
1818
name
@@ -22,6 +22,10 @@ query ListProjectMilestones($projectId: String!, $first: Int!) {
2222
createdAt
2323
updatedAt
2424
}
25+
pageInfo {
26+
hasNextPage
27+
endCursor
28+
}
2529
}
2630
}
2731
}

graphql/queries/projects.graphql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,14 @@ fragment ProjectFields on Project {
3232
#
3333
# Variables:
3434
# $first: Maximum number of projects to return (default: 50)
35-
query GetProjects($first: Int = 50) {
36-
projects(first: $first) {
35+
query GetProjects($first: Int = 50, $after: String) {
36+
projects(first: $first, after: $after) {
3737
nodes {
3838
...ProjectFields
3939
}
40+
pageInfo {
41+
hasNextPage
42+
endCursor
43+
}
4044
}
4145
}

graphql/queries/teams.graphql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,14 @@ fragment TeamFields on Team {
2828
#
2929
# Variables:
3030
# $first: Maximum number of teams to return (default: 50)
31-
query GetTeams($first: Int = 50) {
32-
teams(first: $first) {
31+
query GetTeams($first: Int = 50, $after: String) {
32+
teams(first: $first, after: $after) {
3333
nodes {
3434
...TeamFields
3535
}
36+
pageInfo {
37+
hasNextPage
38+
endCursor
39+
}
3640
}
3741
}

graphql/queries/users.graphql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ fragment UserFields on User {
3131
# Variables:
3232
# $first: Maximum number of users to return (default: 50)
3333
# $filter: Optional filter (e.g., { active: { eq: true } })
34-
query GetUsers($first: Int = 50, $filter: UserFilter) {
35-
users(first: $first, filter: $filter) {
34+
query GetUsers($first: Int = 50, $after: String, $filter: UserFilter) {
35+
users(first: $first, after: $after, filter: $filter) {
3636
nodes {
3737
...UserFields
3838
}
39+
pageInfo {
40+
hasNextPage
41+
endCursor
42+
}
3943
}
4044
}

src/commands/cycles.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
notFoundError,
66
requiresParameterError,
77
} from "../common/errors.js";
8-
import { handleCommand, outputSuccess } from "../common/output.js";
8+
import { handleCommand, outputSuccess, parseLimit } from "../common/output.js";
99
import { type DomainMeta, formatDomainUsage } from "../common/usage.js";
1010
import { resolveCycleId } from "../resolvers/cycle-resolver.js";
1111
import { resolveTeamId } from "../resolvers/team-resolver.js";
@@ -15,6 +15,8 @@ interface CycleListOptions extends CommandOptions {
1515
team?: string;
1616
active?: boolean;
1717
window?: string;
18+
limit: string;
19+
after?: string;
1820
}
1921

2022
interface CycleReadOptions extends CommandOptions {
@@ -46,12 +48,20 @@ export function setupCyclesCommands(program: Command): void {
4648
.option("--team <team>", "filter by team (key, name, or UUID)")
4749
.option("--active", "only show active cycles")
4850
.option("--window <n>", "active cycle +/- n neighbors (requires --team)")
51+
.option("-l, --limit <n>", "max results", "50")
52+
.option("--after <cursor>", "cursor for next page")
4953
.action(
5054
handleCommand(async (...args: unknown[]) => {
5155
const [options, command] = args as [CycleListOptions, Command];
5256
if (options.window && !options.team) {
5357
throw requiresParameterError("--window", "--team");
5458
}
59+
if (options.window && options.after) {
60+
throw invalidParameterError(
61+
"--after",
62+
"cannot be used with --window",
63+
);
64+
}
5565

5666
const ctx = createContext(command.parent!.parent!.opts());
5767

@@ -61,10 +71,11 @@ export function setupCyclesCommands(program: Command): void {
6171
: undefined;
6272

6373
// Fetch cycles
64-
const allCycles = await listCycles(
74+
const result = await listCycles(
6575
ctx.gql,
6676
teamId,
6777
options.active || false,
78+
{ limit: parseLimit(options.limit), after: options.after },
6879
);
6980

7081
if (options.window) {
@@ -76,7 +87,7 @@ export function setupCyclesCommands(program: Command): void {
7687
);
7788
}
7889

79-
const activeCycle = allCycles.find((c: Cycle) => c.isActive);
90+
const activeCycle = result.nodes.find((c: Cycle) => c.isActive);
8091
if (!activeCycle) {
8192
throw notFoundError("Active cycle", options.team ?? "", "for team");
8293
}
@@ -85,15 +96,18 @@ export function setupCyclesCommands(program: Command): void {
8596
const min = activeNumber - n;
8697
const max = activeNumber + n;
8798

88-
const filtered = allCycles
99+
const filteredNodes = result.nodes
89100
.filter((c: Cycle) => c.number >= min && c.number <= max)
90101
.sort((a: Cycle, b: Cycle) => a.number - b.number);
91102

92-
outputSuccess(filtered);
103+
outputSuccess({
104+
nodes: filteredNodes,
105+
pageInfo: { hasNextPage: false, endCursor: null },
106+
});
93107
return;
94108
}
95109

96-
outputSuccess(allCycles);
110+
outputSuccess(result);
97111
}),
98112
);
99113

@@ -116,7 +130,7 @@ export function setupCyclesCommands(program: Command): void {
116130
const cycleResult = await getCycle(
117131
ctx.gql,
118132
cycleId,
119-
parseInt(options.limit || "50", 10),
133+
parseLimit(options.limit || "50"),
120134
);
121135

122136
outputSuccess(cycleResult);

0 commit comments

Comments
 (0)