Skip to content

Commit 3ea85db

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 3ea85db

File tree

2 files changed

+168
-65
lines changed

2 files changed

+168
-65
lines changed

src/commands/issue/list.ts

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
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,
16+
MAX_PAGINATION_PAGES,
1517
} from "../../lib/api-client.js";
1618
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
1719
import { buildCommand } from "../../lib/command.js";
@@ -26,7 +28,12 @@ import {
2628
setProjectAliases,
2729
} from "../../lib/db/project-aliases.js";
2830
import { createDsnFingerprint } from "../../lib/dsn/index.js";
29-
import { ApiError, AuthError, ContextError } from "../../lib/errors.js";
31+
import {
32+
ApiError,
33+
AuthError,
34+
ContextError,
35+
ValidationError,
36+
} from "../../lib/errors.js";
3037
import {
3138
divider,
3239
type FormatShortIdOptions,
@@ -76,6 +83,12 @@ const VALID_SORT_VALUES: SortValue[] = ["date", "new", "freq", "user"];
7683
/** Usage hint for ContextError messages */
7784
const USAGE_HINT = "sentry issue list <org>/<project>";
7885

86+
/**
87+
* Maximum --limit value (auto-pagination ceiling).
88+
* Derived from the page safety bound × the API's per-page cap.
89+
*/
90+
const MAX_LIMIT = MAX_PAGINATION_PAGES * API_MAX_PER_PAGE;
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,44 @@ 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+
// When resuming with --cursor, fetch a single page so the cursor chain stays intact.
444+
if (cursor) {
445+
const perPage = Math.min(flags.limit, API_MAX_PER_PAGE);
446+
const response = await listIssuesPaginated(org, "", {
447+
query: flags.query,
448+
cursor,
449+
perPage,
450+
sort: flags.sort,
451+
});
452+
return { issues: response.data, nextCursor: response.nextCursor };
453+
}
454+
455+
// No cursor — auto-paginate from the beginning via the shared helper.
456+
const { issues, nextCursor } = await listIssuesAllPages(org, "", {
457+
query: flags.query,
458+
limit: flags.limit,
459+
sort: flags.sort,
460+
});
461+
return { issues, nextCursor };
462+
}
463+
409464
/** Options for {@link handleOrgAllIssues}. */
410465
type OrgAllIssuesOptions = {
411466
stdout: Writer;
@@ -431,30 +486,25 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
431486

432487
setContext([org], []);
433488

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

441-
if (response.nextCursor) {
442-
setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor);
491+
if (nextCursor) {
492+
setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor);
443493
} else {
444494
clearPaginationCursor(PAGINATION_KEY, contextKey);
445495
}
446496

447-
const hasMore = !!response.nextCursor;
497+
const hasMore = !!nextCursor;
448498

449499
if (flags.json) {
450500
const output = hasMore
451-
? { data: response.data, nextCursor: response.nextCursor, hasMore: true }
452-
: { data: response.data, hasMore: false };
501+
? { data: issues, nextCursor, hasMore: true }
502+
: { data: issues, hasMore: false };
453503
writeJson(stdout, output);
454504
return;
455505
}
456506

457-
if (response.data.length === 0) {
507+
if (issues.length === 0) {
458508
if (hasMore) {
459509
stdout.write(
460510
`No issues on this page. Try the next page: ${nextPageHint(org, flags)}\n`
@@ -469,7 +519,7 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
469519
// column is needed to identify which project each issue belongs to.
470520
writeListHeader(stdout, `Issues in ${org}`, true);
471521
const termWidth = process.stdout.columns || 80;
472-
const issuesWithOpts = response.data.map((issue) => ({
522+
const issuesWithOpts = issues.map((issue) => ({
473523
issue,
474524
formatOptions: {
475525
projectSlug: issue.project?.slug ?? "",
@@ -479,10 +529,10 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
479529
writeIssueRows(stdout, issuesWithOpts, termWidth);
480530

481531
if (hasMore) {
482-
stdout.write(`\nShowing ${response.data.length} issues (more available)\n`);
532+
stdout.write(`\nShowing ${issues.length} issues (more available)\n`);
483533
stdout.write(`Next page: ${nextPageHint(org, flags)}\n`);
484534
} else {
485-
stdout.write(`\nShowing ${response.data.length} issues\n`);
535+
stdout.write(`\nShowing ${issues.length} issues\n`);
486536
}
487537
}
488538

@@ -664,7 +714,10 @@ export const listCommand = buildCommand({
664714
" sentry issue list <org>/ # all projects in org (trailing / required)\n" +
665715
" sentry issue list <project> # find project across all orgs\n\n" +
666716
`${targetPatternExplanation()}\n\n` +
667-
"In monorepos with multiple Sentry projects, shows issues from all detected projects.",
717+
"In monorepos with multiple Sentry projects, shows issues from all detected projects.\n\n" +
718+
"The --limit flag specifies the total number of results to fetch (max 1000). " +
719+
"When the limit exceeds the API page size (100), multiple requests are made " +
720+
"automatically. Use --cursor to paginate through larger result sets.",
668721
},
669722
parameters: {
670723
positional: LIST_TARGET_POSITIONAL,
@@ -675,7 +728,7 @@ export const listCommand = buildCommand({
675728
brief: "Search query (Sentry search syntax)",
676729
optional: true,
677730
},
678-
limit: buildListLimitFlag("issues", "10"),
731+
limit: buildListLimitFlag("issues", "25"),
679732
sort: {
680733
kind: "parsed",
681734
parse: parseSort,
@@ -703,6 +756,20 @@ export const listCommand = buildCommand({
703756

704757
const parsed = parseOrgProjectArg(target);
705758

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

src/lib/api-client.ts

Lines changed: 81 additions & 45 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,15 +197,21 @@ 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
*/
205-
const MAX_PAGINATION_PAGES = Math.max(
204+
export const MAX_PAGINATION_PAGES = Math.max(
206205
1,
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)