Skip to content

Commit b84affd

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 b84affd

File tree

2 files changed

+166
-64
lines changed

2 files changed

+166
-64
lines changed

src/commands/issue/list.ts

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
import type { SentryContext } from "../../context.js";
99
import { buildOrgAwareAliases } from "../../lib/alias.js";
1010
import {
11+
API_MAX_PER_PAGE,
1112
findProjectsBySlug,
12-
listIssues,
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,12 @@ 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 (user-facing ceiling for practical CLI response times).
87+
* Auto-pagination can theoretically fetch more, but 1000 keeps responses reasonable.
88+
*/
89+
const MAX_LIMIT = 1000;
90+
7991
function parseSort(value: string): SortValue {
8092
if (!VALID_SORT_VALUES.includes(value as SortValue)) {
8193
throw new Error(
@@ -378,7 +390,11 @@ async function fetchIssuesForTarget(
378390
options: { query?: string; limit: number; sort: SortValue }
379391
): Promise<FetchResult> {
380392
try {
381-
const issues = await listIssues(target.org, target.project, options);
393+
const { issues } = await listIssuesAllPages(
394+
target.org,
395+
target.project,
396+
options
397+
);
382398
return { success: true, data: { target, issues } };
383399
} catch (error) {
384400
// Auth errors should propagate - user needs to authenticate
@@ -406,6 +422,44 @@ function nextPageHint(org: string, flags: ListFlags): string {
406422
return parts.length > 0 ? `${base} ${parts.join(" ")}` : base;
407423
}
408424

425+
/** Result from fetching org-wide issues (with or without cursor). */
426+
type OrgAllFetchResult = {
427+
issues: SentryIssue[];
428+
nextCursor?: string;
429+
};
430+
431+
/**
432+
* Fetch org-wide issues, auto-paginating from the start or resuming from a cursor.
433+
*
434+
* When `cursor` is provided (--cursor resume), fetches a single page to keep the
435+
* cursor chain intact. Otherwise auto-paginates up to the requested limit.
436+
*/
437+
async function fetchOrgAllIssues(
438+
org: string,
439+
flags: Pick<ListFlags, "query" | "limit" | "sort">,
440+
cursor: string | undefined
441+
): Promise<OrgAllFetchResult> {
442+
// When resuming with --cursor, fetch a single page so the cursor chain stays intact.
443+
if (cursor) {
444+
const perPage = Math.min(flags.limit, API_MAX_PER_PAGE);
445+
const response = await listIssuesPaginated(org, "", {
446+
query: flags.query,
447+
cursor,
448+
perPage,
449+
sort: flags.sort,
450+
});
451+
return { issues: response.data, nextCursor: response.nextCursor };
452+
}
453+
454+
// No cursor — auto-paginate from the beginning via the shared helper.
455+
const { issues, nextCursor } = await listIssuesAllPages(org, "", {
456+
query: flags.query,
457+
limit: flags.limit,
458+
sort: flags.sort,
459+
});
460+
return { issues, nextCursor };
461+
}
462+
409463
/** Options for {@link handleOrgAllIssues}. */
410464
type OrgAllIssuesOptions = {
411465
stdout: Writer;
@@ -431,30 +485,25 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
431485

432486
setContext([org], []);
433487

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

441-
if (response.nextCursor) {
442-
setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor);
490+
if (nextCursor) {
491+
setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor);
443492
} else {
444493
clearPaginationCursor(PAGINATION_KEY, contextKey);
445494
}
446495

447-
const hasMore = !!response.nextCursor;
496+
const hasMore = !!nextCursor;
448497

449498
if (flags.json) {
450499
const output = hasMore
451-
? { data: response.data, nextCursor: response.nextCursor, hasMore: true }
452-
: { data: response.data, hasMore: false };
500+
? { data: issues, nextCursor, hasMore: true }
501+
: { data: issues, hasMore: false };
453502
writeJson(stdout, output);
454503
return;
455504
}
456505

457-
if (response.data.length === 0) {
506+
if (issues.length === 0) {
458507
if (hasMore) {
459508
stdout.write(
460509
`No issues on this page. Try the next page: ${nextPageHint(org, flags)}\n`
@@ -469,7 +518,7 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
469518
// column is needed to identify which project each issue belongs to.
470519
writeListHeader(stdout, `Issues in ${org}`, true);
471520
const termWidth = process.stdout.columns || 80;
472-
const issuesWithOpts = response.data.map((issue) => ({
521+
const issuesWithOpts = issues.map((issue) => ({
473522
issue,
474523
formatOptions: {
475524
projectSlug: issue.project?.slug ?? "",
@@ -479,10 +528,10 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
479528
writeIssueRows(stdout, issuesWithOpts, termWidth);
480529

481530
if (hasMore) {
482-
stdout.write(`\nShowing ${response.data.length} issues (more available)\n`);
531+
stdout.write(`\nShowing ${issues.length} issues (more available)\n`);
483532
stdout.write(`Next page: ${nextPageHint(org, flags)}\n`);
484533
} else {
485-
stdout.write(`\nShowing ${response.data.length} issues\n`);
534+
stdout.write(`\nShowing ${issues.length} issues\n`);
486535
}
487536
}
488537

@@ -664,7 +713,10 @@ export const listCommand = buildCommand({
664713
" sentry issue list <org>/ # all projects in org (trailing / required)\n" +
665714
" sentry issue list <project> # find project across all orgs\n\n" +
666715
`${targetPatternExplanation()}\n\n` +
667-
"In monorepos with multiple Sentry projects, shows issues from all detected projects.",
716+
"In monorepos with multiple Sentry projects, shows issues from all detected projects.\n\n" +
717+
"The --limit flag specifies the number of results to fetch per project (max 1000). " +
718+
"When the limit exceeds the API page size (100), multiple requests are made " +
719+
"automatically. Use --cursor to paginate through larger result sets.",
668720
},
669721
parameters: {
670722
positional: LIST_TARGET_POSITIONAL,
@@ -675,7 +727,7 @@ export const listCommand = buildCommand({
675727
brief: "Search query (Sentry search syntax)",
676728
optional: true,
677729
},
678-
limit: buildListLimitFlag("issues", "10"),
730+
limit: buildListLimitFlag("issues", "25"),
679731
sort: {
680732
kind: "parsed",
681733
parse: parseSort,
@@ -703,6 +755,20 @@ export const listCommand = buildCommand({
703755

704756
const parsed = parseOrgProjectArg(target);
705757

758+
// Validate --limit range. Auto-pagination handles the API's 100-per-page
759+
// cap transparently, but we cap the total at MAX_LIMIT for practical CLI
760+
// response times. Use --cursor for paginating through larger result sets.
761+
if (flags.limit < 1) {
762+
throw new ValidationError("--limit must be at least 1.", "limit");
763+
}
764+
if (flags.limit > MAX_LIMIT) {
765+
throw new ValidationError(
766+
`--limit cannot exceed ${MAX_LIMIT}. ` +
767+
"Use --cursor to paginate through larger result sets.",
768+
"limit"
769+
);
770+
}
771+
706772
// biome-ignore lint/suspicious/noExplicitAny: shared handler accepts any mode variant
707773
const resolveAndHandle: ModeHandler<any> = (ctx) =>
708774
handleResolvedTargets({ ...ctx, flags, stderr, setContext });

src/lib/api-client.ts

Lines changed: 80 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
*/
1010

1111
import {
12-
listAnOrganization_sIssues,
1312
listAnOrganization_sTeams,
1413
listAProject_sClientKeys,
1514
listAProject_sTeams,
@@ -198,7 +197,7 @@ function extractLinkAttr(segment: string, attr: string): string | undefined {
198197
* Maximum number of pages to follow when auto-paginating.
199198
*
200199
* Safety limit to prevent runaway pagination when the API returns an unexpectedly
201-
* large number of pages. At 100 items/page this allows up to 5,000 items, which
200+
* large number of pages. At API_MAX_PER_PAGE items/page this allows up to 5,000 items, which
202201
* covers even the largest organizations. Override with SENTRY_MAX_PAGINATION_PAGES
203202
* env var for edge cases.
204203
*/
@@ -207,6 +206,12 @@ const MAX_PAGINATION_PAGES = Math.max(
207206
Number(process.env.SENTRY_MAX_PAGINATION_PAGES) || 50
208207
);
209208

209+
/**
210+
* Sentry API's maximum items per page.
211+
* Requests for more items are silently capped server-side.
212+
*/
213+
export const API_MAX_PER_PAGE = 100;
214+
210215
/**
211216
* Paginated API response with cursor metadata.
212217
* More pages exist when `nextCursor` is defined.
@@ -512,13 +517,13 @@ async function orgScopedRequestPaginated<T>(
512517
*
513518
* @param endpoint - API endpoint path containing the org slug
514519
* @param options - Request options (schema must validate an array type)
515-
* @param perPage - Number of items per API page (default: 100)
520+
* @param perPage - Number of items per API page (default: API_MAX_PER_PAGE)
516521
* @returns Combined array of all results across all pages
517522
*/
518523
async function orgScopedPaginateAll<T>(
519524
endpoint: string,
520525
options: ApiRequestOptions<T[]>,
521-
perPage = 100
526+
perPage = API_MAX_PER_PAGE
522527
): Promise<T[]> {
523528
const allResults: T[] = [];
524529
let cursor: string | undefined;
@@ -655,7 +660,7 @@ export function listProjectsPaginated(
655660
`/organizations/${orgSlug}/projects/`,
656661
{
657662
params: {
658-
per_page: options.perPage ?? 100,
663+
per_page: options.perPage ?? API_MAX_PER_PAGE,
659664
cursor: options.cursor,
660665
},
661666
}
@@ -982,44 +987,6 @@ export async function getProjectKeys(
982987

983988
// Issue functions
984989

985-
/**
986-
* List issues for a project.
987-
* Uses the org-scoped endpoint (the project-scoped one is deprecated).
988-
* Uses region-aware routing for multi-region support.
989-
*/
990-
export async function listIssues(
991-
orgSlug: string,
992-
projectSlug: string,
993-
options: {
994-
query?: string;
995-
cursor?: string;
996-
limit?: number;
997-
sort?: "date" | "new" | "freq" | "user";
998-
statsPeriod?: string;
999-
} = {}
1000-
): Promise<SentryIssue[]> {
1001-
const config = await getOrgSdkConfig(orgSlug);
1002-
1003-
// Build query with project filter: "project:{slug}" prefix
1004-
const projectFilter = `project:${projectSlug}`;
1005-
const fullQuery = [projectFilter, options.query].filter(Boolean).join(" ");
1006-
1007-
const result = await listAnOrganization_sIssues({
1008-
...config,
1009-
path: { organization_id_or_slug: orgSlug },
1010-
query: {
1011-
query: fullQuery,
1012-
cursor: options.cursor,
1013-
limit: options.limit,
1014-
sort: options.sort,
1015-
statsPeriod: options.statsPeriod,
1016-
},
1017-
});
1018-
1019-
const data = unwrapResult(result, "Failed to list issues");
1020-
return data as unknown as SentryIssue[];
1021-
}
1022-
1023990
/**
1024991
* List issues for a project with pagination control.
1025992
* Returns a single page of results with cursor metadata for manual pagination.
@@ -1064,6 +1031,75 @@ export function listIssuesPaginated(
10641031
);
10651032
}
10661033

1034+
/** Result from {@link listIssuesAllPages}. */
1035+
export type IssuesPage = {
1036+
issues: SentryIssue[];
1037+
/**
1038+
* Cursor for the next page of results, if more exist beyond the returned
1039+
* issues. `undefined` when all matching issues have been returned OR when
1040+
* the last page was trimmed to fit `limit` (cursor would skip items).
1041+
*/
1042+
nextCursor?: string;
1043+
};
1044+
1045+
/**
1046+
* Auto-paginate through issues up to the requested limit.
1047+
*
1048+
* The Sentry API caps `per_page` at {@link API_MAX_PER_PAGE} server-side. When the caller
1049+
* requests more than that, this function transparently fetches multiple
1050+
* pages using cursor-based pagination and returns the combined result.
1051+
*
1052+
* Safety-bounded by {@link MAX_PAGINATION_PAGES} to prevent runaway requests.
1053+
*
1054+
* @param orgSlug - Organization slug
1055+
* @param projectSlug - Project slug (empty string for org-wide)
1056+
* @param options - Query, sort, and limit options
1057+
* @returns Issues (up to `limit` items) and a cursor for the next page if available
1058+
*/
1059+
export async function listIssuesAllPages(
1060+
orgSlug: string,
1061+
projectSlug: string,
1062+
options: {
1063+
query?: string;
1064+
limit: number;
1065+
sort?: "date" | "new" | "freq" | "user";
1066+
statsPeriod?: string;
1067+
}
1068+
): Promise<IssuesPage> {
1069+
const allResults: SentryIssue[] = [];
1070+
let cursor: string | undefined;
1071+
1072+
// Use the smaller of the requested limit and the API max as page size
1073+
const perPage = Math.min(options.limit, API_MAX_PER_PAGE);
1074+
1075+
for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
1076+
const response = await listIssuesPaginated(orgSlug, projectSlug, {
1077+
query: options.query,
1078+
cursor,
1079+
perPage,
1080+
sort: options.sort,
1081+
statsPeriod: options.statsPeriod,
1082+
});
1083+
1084+
allResults.push(...response.data);
1085+
1086+
// Stop if we've reached the requested limit or there are no more pages
1087+
if (allResults.length >= options.limit || !response.nextCursor) {
1088+
// If we overshot the limit, trim and don't return a nextCursor —
1089+
// the cursor would point past the trimmed items, causing skips.
1090+
if (allResults.length > options.limit) {
1091+
return { issues: allResults.slice(0, options.limit) };
1092+
}
1093+
return { issues: allResults, nextCursor: response.nextCursor };
1094+
}
1095+
1096+
cursor = response.nextCursor;
1097+
}
1098+
1099+
// Safety limit reached — return what we have, no nextCursor
1100+
return { issues: allResults.slice(0, options.limit) };
1101+
}
1102+
10671103
/**
10681104
* Get a specific issue by numeric ID.
10691105
*/
@@ -1443,7 +1479,7 @@ export async function listLogs(
14431479
field: LOG_FIELDS,
14441480
project: isNumericProject ? [Number(projectSlug)] : undefined,
14451481
query: fullQuery || undefined,
1446-
per_page: options.limit || 100,
1482+
per_page: options.limit || API_MAX_PER_PAGE,
14471483
statsPeriod: options.statsPeriod ?? "7d",
14481484
sort: "-timestamp",
14491485
},

0 commit comments

Comments
 (0)