Skip to content

Commit 9b42e75

Browse files
committed
feat(list): add pagination and consistent target parsing to all list commands
1 parent 138b073 commit 9b42e75

File tree

10 files changed

+1113
-552
lines changed

10 files changed

+1113
-552
lines changed

src/commands/issue/list.ts

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,27 @@ import { buildOrgAwareAliases } from "../../lib/alias.js";
1010
import {
1111
findProjectsBySlug,
1212
listIssues,
13+
listIssuesPaginated,
1314
listProjects,
1415
} from "../../lib/api-client.js";
1516
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
1617
import { buildCommand, numberParser } from "../../lib/command.js";
18+
import {
19+
clearPaginationCursor,
20+
getPaginationCursor,
21+
setPaginationCursor,
22+
} from "../../lib/db/pagination.js";
1723
import {
1824
clearProjectAliases,
1925
setProjectAliases,
2026
} from "../../lib/db/project-aliases.js";
2127
import { createDsnFingerprint } from "../../lib/dsn/index.js";
22-
import { ApiError, AuthError, ContextError } from "../../lib/errors.js";
28+
import {
29+
ApiError,
30+
AuthError,
31+
ContextError,
32+
ValidationError,
33+
} from "../../lib/errors.js";
2334
import {
2435
divider,
2536
type FormatShortIdOptions,
@@ -32,17 +43,22 @@ import {
3243
type ResolvedTarget,
3344
resolveAllTargets,
3445
} from "../../lib/resolve-target.js";
46+
import { getApiBaseUrl } from "../../lib/sentry-client.js";
3547
import type {
3648
ProjectAliasEntry,
3749
SentryIssue,
3850
Writer,
3951
} from "../../types/index.js";
4052

53+
/** Command key for pagination cursor storage */
54+
export const PAGINATION_KEY = "issue-list";
55+
4156
type ListFlags = {
4257
readonly query?: string;
4358
readonly limit: number;
4459
readonly sort: "date" | "new" | "freq" | "user";
4560
readonly json: boolean;
61+
readonly cursor?: string;
4662
};
4763

4864
type SortValue = "date" | "new" | "freq" | "user";
@@ -429,8 +445,15 @@ export const listCommand = buildCommand({
429445
brief: "Output as JSON",
430446
default: false,
431447
},
448+
cursor: {
449+
kind: "parsed",
450+
parse: String,
451+
brief:
452+
'Pagination cursor — only for <org>/ mode (use "last" to continue)',
453+
optional: true,
454+
},
432455
},
433-
aliases: { q: "query", s: "sort", n: "limit" },
456+
aliases: { q: "query", s: "sort", n: "limit", c: "cursor" },
434457
},
435458
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity
436459
async func(
@@ -443,6 +466,96 @@ export const listCommand = buildCommand({
443466
// Parse positional argument to determine resolution strategy
444467
const parsed = parseOrgProjectArg(target);
445468

469+
// Cursor pagination is only supported in org-all mode
470+
if (flags.cursor && parsed.type !== "org-all") {
471+
throw new ValidationError(
472+
"The --cursor flag is only supported when listing issues for a specific organization " +
473+
"(e.g., sentry issue list <org>/). " +
474+
"Use 'sentry issue list <org>/' for paginated results.",
475+
"cursor"
476+
);
477+
}
478+
479+
// Handle org-all mode with cursor pagination (different code path)
480+
if (parsed.type === "org-all") {
481+
const org = parsed.org;
482+
const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${flags.query ? `|q:${flags.query}` : ""}`;
483+
let cursor: string | undefined;
484+
if (flags.cursor) {
485+
if (flags.cursor === "last") {
486+
const cached = getPaginationCursor(PAGINATION_KEY, contextKey);
487+
if (!cached) {
488+
throw new ContextError(
489+
"Pagination cursor",
490+
"No saved cursor for this query. Run without --cursor first."
491+
);
492+
}
493+
cursor = cached;
494+
} else {
495+
cursor = flags.cursor;
496+
}
497+
}
498+
499+
setContext([org], []);
500+
501+
const response = await listIssuesPaginated(org, "", {
502+
query: flags.query,
503+
cursor,
504+
perPage: flags.limit,
505+
sort: flags.sort,
506+
});
507+
508+
// Strip the project filter since we're listing org-wide (pass empty projectSlug)
509+
// The API handles org-wide issue listing without a project filter
510+
511+
if (response.nextCursor) {
512+
setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor);
513+
} else {
514+
clearPaginationCursor(PAGINATION_KEY, contextKey);
515+
}
516+
517+
const hasMore = !!response.nextCursor;
518+
519+
if (flags.json) {
520+
const output = hasMore
521+
? {
522+
data: response.data,
523+
nextCursor: response.nextCursor,
524+
hasMore: true,
525+
}
526+
: { data: response.data, hasMore: false };
527+
writeJson(stdout, output);
528+
return;
529+
}
530+
531+
if (response.data.length === 0) {
532+
stdout.write(`No issues found in organization '${org}'.\n`);
533+
return;
534+
}
535+
536+
writeListHeader(stdout, `Issues in ${org}`, false);
537+
const termWidth = process.stdout.columns || 80;
538+
const issuesWithOpts = response.data.map((issue) => ({
539+
issue,
540+
formatOptions: {
541+
projectSlug: issue.project?.slug ?? "",
542+
isMultiProject: false,
543+
},
544+
}));
545+
writeIssueRows(stdout, issuesWithOpts, termWidth);
546+
547+
if (hasMore) {
548+
const hint = `sentry issue list ${org}/ -c last`;
549+
stdout.write(
550+
`\nShowing ${response.data.length} issues (more available)\n`
551+
);
552+
stdout.write(`Next page: ${hint}\n`);
553+
} else {
554+
stdout.write(`\nShowing ${response.data.length} issues\n`);
555+
}
556+
return;
557+
}
558+
446559
// Resolve targets based on parsed argument type
447560
const { targets, footer, skippedSelfHosted, detectedDsns } =
448561
await resolveTargetsFromParsedArg(parsed, cwd);

src/commands/log/list.ts

Lines changed: 16 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@
88
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
99
import * as Sentry from "@sentry/bun";
1010
import type { SentryContext } from "../../context.js";
11-
import { findProjectsBySlug, listLogs } from "../../lib/api-client.js";
12-
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
11+
import { listLogs } from "../../lib/api-client.js";
12+
import { validateLimit } from "../../lib/arg-parsing.js";
1313
import { buildCommand } from "../../lib/command.js";
14-
import { AuthError, ContextError } from "../../lib/errors.js";
14+
import { AuthError } from "../../lib/errors.js";
1515
import {
1616
formatLogRow,
1717
formatLogsHeader,
1818
writeFooter,
1919
writeJson,
2020
} from "../../lib/formatters/index.js";
21-
import { resolveOrgAndProject } from "../../lib/resolve-target.js";
21+
import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js";
2222
import { getUpdateNotification } from "../../lib/version-check.js";
2323
import type { SentryLog, Writer } from "../../types/index.js";
2424

@@ -29,9 +29,6 @@ type ListFlags = {
2929
readonly json: boolean;
3030
};
3131

32-
/** Usage hint for ContextError messages */
33-
const USAGE_HINT = "sentry log list <org>/<project>";
34-
3532
/** Maximum allowed value for --limit flag */
3633
const MAX_LIMIT = 1000;
3734

@@ -44,17 +41,14 @@ const DEFAULT_LIMIT = 100;
4441
/** Default poll interval in seconds for --follow mode */
4542
const DEFAULT_POLL_INTERVAL = 2;
4643

44+
/** Command name used in resolver error messages */
45+
const COMMAND_NAME = "log list";
46+
4747
/**
48-
* Validate that --limit value is within allowed range.
49-
*
50-
* @throws Error if value is outside MIN_LIMIT..MAX_LIMIT range
48+
* Parse --limit flag, delegating range validation to shared utility.
5149
*/
52-
function validateLimit(value: string): number {
53-
const num = Number.parseInt(value, 10);
54-
if (Number.isNaN(num) || num < MIN_LIMIT || num > MAX_LIMIT) {
55-
throw new Error(`--limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}`);
56-
}
57-
return num;
50+
function parseLimit(value: string): number {
51+
return validateLimit(value, MIN_LIMIT, MAX_LIMIT);
5852
}
5953

6054
/**
@@ -233,83 +227,6 @@ async function executeFollowMode(options: FollowModeOptions): Promise<void> {
233227
}
234228
}
235229

236-
/** Resolved org and project for log commands */
237-
type ResolvedLogTarget = {
238-
org: string;
239-
project: string;
240-
};
241-
242-
/**
243-
* Resolve org/project from parsed argument or auto-detection.
244-
*
245-
* Handles:
246-
* - explicit: "org/project" → use directly
247-
* - project-search: "project" → find project across all orgs
248-
* - auto-detect: no input → use DSN detection or config defaults
249-
*
250-
* @throws {ContextError} When target cannot be resolved
251-
*/
252-
async function resolveLogTarget(
253-
target: string | undefined,
254-
cwd: string
255-
): Promise<ResolvedLogTarget> {
256-
const parsed = parseOrgProjectArg(target);
257-
258-
switch (parsed.type) {
259-
case "explicit":
260-
return { org: parsed.org, project: parsed.project };
261-
262-
case "org-all":
263-
throw new ContextError(
264-
"Project",
265-
`Please specify a project: sentry log list ${parsed.org}/<project>`
266-
);
267-
268-
case "project-search": {
269-
// Find project across all orgs
270-
const matches = await findProjectsBySlug(parsed.projectSlug);
271-
272-
if (matches.length === 0) {
273-
throw new ContextError(
274-
"Project",
275-
`No project '${parsed.projectSlug}' found in any accessible organization.\n\n` +
276-
`Try: sentry log list <org>/${parsed.projectSlug}`
277-
);
278-
}
279-
280-
if (matches.length > 1) {
281-
const options = matches
282-
.map((m) => ` sentry log list ${m.orgSlug}/${m.slug}`)
283-
.join("\n");
284-
throw new ContextError(
285-
"Project",
286-
`Found '${parsed.projectSlug}' in ${matches.length} organizations. Please specify:\n${options}`
287-
);
288-
}
289-
290-
// Safe: we checked matches.length === 1 above, so first element exists
291-
const match = matches[0] as (typeof matches)[number];
292-
return { org: match.orgSlug, project: match.slug };
293-
}
294-
295-
case "auto-detect": {
296-
const resolved = await resolveOrgAndProject({
297-
cwd,
298-
usageHint: USAGE_HINT,
299-
});
300-
if (!resolved) {
301-
throw new ContextError("Organization and project", USAGE_HINT);
302-
}
303-
return { org: resolved.org, project: resolved.project };
304-
}
305-
306-
default: {
307-
const _exhaustiveCheck: never = parsed;
308-
throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`);
309-
}
310-
}
311-
}
312-
313230
export const listCommand = buildCommand({
314231
docs: {
315232
brief: "List logs from a project",
@@ -341,7 +258,7 @@ export const listCommand = buildCommand({
341258
flags: {
342259
limit: {
343260
kind: "parsed",
344-
parse: validateLimit,
261+
parse: parseLimit,
345262
brief: `Number of log entries (${MIN_LIMIT}-${MAX_LIMIT})`,
346263
default: String(DEFAULT_LIMIT),
347264
},
@@ -378,7 +295,11 @@ export const listCommand = buildCommand({
378295
const { stdout, stderr, cwd, setContext } = this;
379296

380297
// Resolve org/project from positional arg, config, or DSN auto-detection
381-
const { org, project } = await resolveLogTarget(target, cwd);
298+
const { org, project } = await resolveOrgProjectFromArg(
299+
target,
300+
cwd,
301+
COMMAND_NAME
302+
);
382303
setContext([org], [project]);
383304

384305
if (flags.follow) {

0 commit comments

Comments
 (0)