Skip to content

Commit f968692

Browse files
committed
fix(issue-list): auto-paginate --limit beyond 100 (#268)
When users passed --limit > 100, the Sentry API silently capped results at 100. The CLI now transparently fetches multiple pages to satisfy the requested limit. - Add listIssuesAllPages() in api-client.ts: cursor-paginates up to the requested limit using listIssuesPaginated, bounded by MAX_PAGINATION_PAGES - Switch fetchIssuesForTarget to use listIssuesAllPages instead of listIssues - Cap --limit at 1000 (non-org-all) and 100 (org-all page size) with a clear ValidationError pointing to cursor pagination for larger sets - Change default --limit from 10 to 25 - Update help text to document auto-pagination semantics
1 parent 24fe62c commit f968692

File tree

2 files changed

+166
-20
lines changed

2 files changed

+166
-20
lines changed

src/commands/issue/list.ts

Lines changed: 107 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import type { SentryContext } from "../../context.js";
99
import { buildOrgAwareAliases } from "../../lib/alias.js";
1010
import {
1111
findProjectsBySlug,
12-
listIssues,
12+
ISSUES_MAX_PER_PAGE,
13+
listIssuesAllPages,
1314
listIssuesPaginated,
1415
listProjects,
1516
} from "../../lib/api-client.js";
@@ -26,7 +27,12 @@ import {
2627
setProjectAliases,
2728
} from "../../lib/db/project-aliases.js";
2829
import { createDsnFingerprint } from "../../lib/dsn/index.js";
29-
import { ApiError, AuthError, ContextError } from "../../lib/errors.js";
30+
import {
31+
ApiError,
32+
AuthError,
33+
ContextError,
34+
ValidationError,
35+
} from "../../lib/errors.js";
3036
import {
3137
divider,
3238
type FormatShortIdOptions,
@@ -76,6 +82,13 @@ const VALID_SORT_VALUES: SortValue[] = ["date", "new", "freq", "user"];
7682
/** Usage hint for ContextError messages */
7783
const USAGE_HINT = "sentry issue list <org>/<project>";
7884

85+
/**
86+
* Maximum --limit value (auto-pagination ceiling).
87+
* Equals MAX_PAGINATION_PAGES * ISSUES_MAX_PER_PAGE — the theoretical maximum
88+
* the auto-paginator can return. Kept lower for practical CLI response times.
89+
*/
90+
const MAX_LIMIT = 1000;
91+
7992
function parseSort(value: string): SortValue {
8093
if (!VALID_SORT_VALUES.includes(value as SortValue)) {
8194
throw new Error(
@@ -378,7 +391,11 @@ async function fetchIssuesForTarget(
378391
options: { query?: string; limit: number; sort: SortValue }
379392
): Promise<FetchResult> {
380393
try {
381-
const issues = await listIssues(target.org, target.project, options);
394+
const issues = await listIssuesAllPages(
395+
target.org,
396+
target.project,
397+
options
398+
);
382399
return { success: true, data: { target, issues } };
383400
} catch (error) {
384401
// Auth errors should propagate - user needs to authenticate
@@ -406,6 +423,67 @@ function nextPageHint(org: string, flags: ListFlags): string {
406423
return parts.length > 0 ? `${base} ${parts.join(" ")}` : base;
407424
}
408425

426+
/** Result from fetching org-wide issues (with or without cursor). */
427+
type OrgAllFetchResult = {
428+
issues: SentryIssue[];
429+
nextCursor?: string;
430+
};
431+
432+
/**
433+
* Fetch org-wide issues, auto-paginating from the start or resuming from a cursor.
434+
*
435+
* When `cursor` is provided (--cursor resume), fetches a single page to keep the
436+
* cursor chain intact. Otherwise auto-paginates up to the requested limit.
437+
*/
438+
async function fetchOrgAllIssues(
439+
org: string,
440+
flags: Pick<ListFlags, "query" | "limit" | "sort">,
441+
cursor: string | undefined
442+
): Promise<OrgAllFetchResult> {
443+
const perPage = Math.min(flags.limit, ISSUES_MAX_PER_PAGE);
444+
445+
// When resuming with --cursor, fetch a single page so the cursor chain stays intact.
446+
if (cursor) {
447+
const response = await listIssuesPaginated(org, "", {
448+
query: flags.query,
449+
cursor,
450+
perPage,
451+
sort: flags.sort,
452+
});
453+
return { issues: response.data, nextCursor: response.nextCursor };
454+
}
455+
456+
// No cursor — auto-paginate from the beginning.
457+
const issues: SentryIssue[] = [];
458+
let pageCursor: string | undefined;
459+
let lastCursor: string | undefined;
460+
461+
// Safety-bounded at 50 pages to match the auto-paginator in api-client.ts.
462+
const MAX_PAGES = 50;
463+
for (let page = 0; issues.length < flags.limit && page < MAX_PAGES; page++) {
464+
const response = await listIssuesPaginated(org, "", {
465+
query: flags.query,
466+
cursor: pageCursor,
467+
perPage,
468+
sort: flags.sort,
469+
});
470+
471+
issues.push(...response.data);
472+
lastCursor = response.nextCursor;
473+
474+
if (!response.nextCursor) {
475+
break;
476+
}
477+
pageCursor = response.nextCursor;
478+
}
479+
480+
// Trim to exact limit (last page may overshoot)
481+
return {
482+
issues: issues.length > flags.limit ? issues.slice(0, flags.limit) : issues,
483+
nextCursor: lastCursor,
484+
};
485+
}
486+
409487
/** Options for {@link handleOrgAllIssues}. */
410488
type OrgAllIssuesOptions = {
411489
stdout: Writer;
@@ -431,30 +509,25 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
431509

432510
setContext([org], []);
433511

434-
const response = await listIssuesPaginated(org, "", {
435-
query: flags.query,
436-
cursor,
437-
perPage: flags.limit,
438-
sort: flags.sort,
439-
});
512+
const { issues, nextCursor } = await fetchOrgAllIssues(org, flags, cursor);
440513

441-
if (response.nextCursor) {
442-
setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor);
514+
if (nextCursor) {
515+
setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor);
443516
} else {
444517
clearPaginationCursor(PAGINATION_KEY, contextKey);
445518
}
446519

447-
const hasMore = !!response.nextCursor;
520+
const hasMore = !!nextCursor;
448521

449522
if (flags.json) {
450523
const output = hasMore
451-
? { data: response.data, nextCursor: response.nextCursor, hasMore: true }
452-
: { data: response.data, hasMore: false };
524+
? { data: issues, nextCursor, hasMore: true }
525+
: { data: issues, hasMore: false };
453526
writeJson(stdout, output);
454527
return;
455528
}
456529

457-
if (response.data.length === 0) {
530+
if (issues.length === 0) {
458531
if (hasMore) {
459532
stdout.write(
460533
`No issues on this page. Try the next page: ${nextPageHint(org, flags)}\n`
@@ -469,7 +542,7 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
469542
// column is needed to identify which project each issue belongs to.
470543
writeListHeader(stdout, `Issues in ${org}`, true);
471544
const termWidth = process.stdout.columns || 80;
472-
const issuesWithOpts = response.data.map((issue) => ({
545+
const issuesWithOpts = issues.map((issue) => ({
473546
issue,
474547
formatOptions: {
475548
projectSlug: issue.project?.slug ?? "",
@@ -479,10 +552,10 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
479552
writeIssueRows(stdout, issuesWithOpts, termWidth);
480553

481554
if (hasMore) {
482-
stdout.write(`\nShowing ${response.data.length} issues (more available)\n`);
555+
stdout.write(`\nShowing ${issues.length} issues (more available)\n`);
483556
stdout.write(`Next page: ${nextPageHint(org, flags)}\n`);
484557
} else {
485-
stdout.write(`\nShowing ${response.data.length} issues\n`);
558+
stdout.write(`\nShowing ${issues.length} issues\n`);
486559
}
487560
}
488561

@@ -664,7 +737,10 @@ export const listCommand = buildCommand({
664737
" sentry issue list <org>/ # all projects in org (trailing / required)\n" +
665738
" sentry issue list <project> # find project across all orgs\n\n" +
666739
`${targetPatternExplanation()}\n\n` +
667-
"In monorepos with multiple Sentry projects, shows issues from all detected projects.",
740+
"In monorepos with multiple Sentry projects, shows issues from all detected projects.\n\n" +
741+
"The --limit flag specifies the total number of results to fetch (max 1000). " +
742+
"When the limit exceeds the API page size (100), multiple requests are made " +
743+
"automatically. Use --cursor to paginate through larger result sets.",
668744
},
669745
parameters: {
670746
positional: LIST_TARGET_POSITIONAL,
@@ -675,7 +751,7 @@ export const listCommand = buildCommand({
675751
brief: "Search query (Sentry search syntax)",
676752
optional: true,
677753
},
678-
limit: buildListLimitFlag("issues", "10"),
754+
limit: buildListLimitFlag("issues", "25"),
679755
sort: {
680756
kind: "parsed",
681757
parse: parseSort,
@@ -703,6 +779,17 @@ export const listCommand = buildCommand({
703779

704780
const parsed = parseOrgProjectArg(target);
705781

782+
// Validate --limit range. Auto-pagination handles the API's 100-per-page
783+
// cap transparently, but we cap the total at MAX_LIMIT for practical CLI
784+
// response times. Use --cursor for paginating through larger result sets.
785+
if (flags.limit > MAX_LIMIT) {
786+
throw new ValidationError(
787+
`--limit cannot exceed ${MAX_LIMIT}. ` +
788+
"Use --cursor to paginate through larger result sets.",
789+
"limit"
790+
);
791+
}
792+
706793
// biome-ignore lint/suspicious/noExplicitAny: shared handler accepts any mode variant
707794
const resolveAndHandle: ModeHandler<any> = (ctx) =>
708795
handleResolvedTargets({ ...ctx, flags, stderr, setContext });

src/lib/api-client.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,65 @@ export function listIssuesPaginated(
10641064
);
10651065
}
10661066

1067+
/**
1068+
* Sentry API's maximum items per page for the issues endpoint.
1069+
* Requests above this are silently capped server-side.
1070+
*/
1071+
export const ISSUES_MAX_PER_PAGE = 100;
1072+
1073+
/**
1074+
* Auto-paginate through issues up to the requested limit.
1075+
*
1076+
* The Sentry issues API caps `per_page` at 100 server-side. When the caller
1077+
* requests more than 100 issues, this function transparently fetches multiple
1078+
* pages using cursor-based pagination and returns the combined result.
1079+
*
1080+
* Safety-bounded by {@link MAX_PAGINATION_PAGES} to prevent runaway requests.
1081+
*
1082+
* @param orgSlug - Organization slug
1083+
* @param projectSlug - Project slug (empty string for org-wide)
1084+
* @param options - Query, sort, and limit options
1085+
* @returns Combined array of issues (up to `limit` items)
1086+
*/
1087+
export async function listIssuesAllPages(
1088+
orgSlug: string,
1089+
projectSlug: string,
1090+
options: {
1091+
query?: string;
1092+
limit: number;
1093+
sort?: "date" | "new" | "freq" | "user";
1094+
statsPeriod?: string;
1095+
}
1096+
): Promise<SentryIssue[]> {
1097+
const allResults: SentryIssue[] = [];
1098+
let cursor: string | undefined;
1099+
1100+
// Use the smaller of the requested limit and the API max as page size
1101+
const perPage = Math.min(options.limit, ISSUES_MAX_PER_PAGE);
1102+
1103+
for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
1104+
const response = await listIssuesPaginated(orgSlug, projectSlug, {
1105+
query: options.query,
1106+
cursor,
1107+
perPage,
1108+
sort: options.sort,
1109+
statsPeriod: options.statsPeriod,
1110+
});
1111+
1112+
allResults.push(...response.data);
1113+
1114+
// Stop if we've reached the requested limit or there are no more pages
1115+
if (allResults.length >= options.limit || !response.nextCursor) {
1116+
break;
1117+
}
1118+
1119+
cursor = response.nextCursor;
1120+
}
1121+
1122+
// Trim to exact limit (last page may overshoot)
1123+
return allResults.slice(0, options.limit);
1124+
}
1125+
10671126
/**
10681127
* Get a specific issue by numeric ID.
10691128
*/

0 commit comments

Comments
 (0)