Skip to content

Commit ddd62af

Browse files
feat(issue-list): add --period flag, pagination progress, and count abbreviation (#289)
## Summary Three UX improvements to `sentry issue list` following the auto-pagination work in #274: - **Fix implicit time filtering** — add `--period` / `-t` flag (default `90d`) so the CLI matches the UI's default time window instead of the Sentry API's implicit 14d cutoff, which was causing the count discrepancy (e.g. 114 vs 240 issues) - **Pagination progress spinner** — animated braille spinner on stderr while fetching multiple pages (`Fetching issues... 200/500`); reuses `withProgress<T>()` in `polling.ts` so the animation is consistent with `sentry issue explain` - **Fix COUNT column layout shift** — abbreviate event counts ≥10k with K/M/B/T/P/E suffixes (`12.3K`, `150K`, `1.5M`) so the column stays exactly 5 chars wide and never overflows ## Details - `--period` alias is `-t` (time period), not `-p` (avoids confusion with `--platform` in other commands) - `onPage` callback added to `listIssuesAllPages` for per-page progress hooks - Animation interval changed from 80ms to 50ms (20fps / 500ms full braille cycle), matching the ora/inquirer standard — applies to Seer commands too - Decimal shown only when `scaled < 100` (e.g. `12.3K` and `1.5M` but not `100.0M`) --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 9d95888 commit ddd62af

File tree

6 files changed

+218
-37
lines changed

6 files changed

+218
-37
lines changed

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ List issues in a project
204204
- `-q, --query <value> - Search query (Sentry search syntax)`
205205
- `-n, --limit <value> - Maximum number of issues to list - (default: "25")`
206206
- `-s, --sort <value> - Sort by: date, new, freq, user - (default: "date")`
207+
- `-t, --period <value> - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")`
207208
- `--json - Output JSON`
208209
- `-c, --cursor <value> - Pagination cursor — only for <org>/ mode (use "last" to continue)`
209210

@@ -594,6 +595,7 @@ List issues in a project
594595
- `-q, --query <value> - Search query (Sentry search syntax)`
595596
- `-n, --limit <value> - Maximum number of issues to list - (default: "25")`
596597
- `-s, --sort <value> - Sort by: date, new, freq, user - (default: "date")`
598+
- `-t, --period <value> - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")`
597599
- `--json - Output JSON`
598600
- `-c, --cursor <value> - Pagination cursor — only for <org>/ mode (use "last" to continue)`
599601

src/commands/issue/list.ts

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
type ListCommandMeta,
5555
type ModeHandler,
5656
} from "../../lib/org-list.js";
57+
import { withProgress } from "../../lib/polling.js";
5758
import {
5859
type ResolvedTarget,
5960
resolveAllTargets,
@@ -72,6 +73,7 @@ type ListFlags = {
7273
readonly query?: string;
7374
readonly limit: number;
7475
readonly sort: "date" | "new" | "freq" | "user";
76+
readonly period: string;
7577
readonly json: boolean;
7678
readonly cursor?: string;
7779
};
@@ -391,7 +393,13 @@ async function resolveTargetsFromParsedArg(
391393
*/
392394
async function fetchIssuesForTarget(
393395
target: ResolvedTarget,
394-
options: { query?: string; limit: number; sort: SortValue }
396+
options: {
397+
query?: string;
398+
limit: number;
399+
sort: SortValue;
400+
statsPeriod?: string;
401+
onPage?: (fetched: number, limit: number) => void;
402+
}
395403
): Promise<FetchResult> {
396404
try {
397405
const { issues } = await listIssuesAllPages(
@@ -423,6 +431,9 @@ function nextPageHint(org: string, flags: ListFlags): string {
423431
if (flags.query) {
424432
parts.push(`-q "${flags.query}"`);
425433
}
434+
if (flags.period !== "90d") {
435+
parts.push(`-t ${flags.period}`);
436+
}
426437
return parts.length > 0 ? `${base} ${parts.join(" ")}` : base;
427438
}
428439

@@ -434,8 +445,9 @@ function nextPageHint(org: string, flags: ListFlags): string {
434445
*/
435446
async function fetchOrgAllIssues(
436447
org: string,
437-
flags: Pick<ListFlags, "query" | "limit" | "sort">,
438-
cursor: string | undefined
448+
flags: Pick<ListFlags, "query" | "limit" | "sort" | "period">,
449+
cursor: string | undefined,
450+
onPage?: (fetched: number, limit: number) => void
439451
): Promise<IssuesPage> {
440452
// When resuming with --cursor, fetch a single page so the cursor chain stays intact.
441453
if (cursor) {
@@ -445,6 +457,7 @@ async function fetchOrgAllIssues(
445457
cursor,
446458
perPage,
447459
sort: flags.sort,
460+
statsPeriod: flags.period,
448461
});
449462
return { issues: response.data, nextCursor: response.nextCursor };
450463
}
@@ -454,13 +467,16 @@ async function fetchOrgAllIssues(
454467
query: flags.query,
455468
limit: flags.limit,
456469
sort: flags.sort,
470+
statsPeriod: flags.period,
471+
onPage,
457472
});
458473
return { issues, nextCursor };
459474
}
460475

461476
/** Options for {@link handleOrgAllIssues}. */
462477
type OrgAllIssuesOptions = {
463478
stdout: Writer;
479+
stderr: Writer;
464480
org: string;
465481
flags: ListFlags;
466482
setContext: (orgs: string[], projects: string[]) => void;
@@ -473,17 +489,24 @@ type OrgAllIssuesOptions = {
473489
* never accidentally reused.
474490
*/
475491
async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
476-
const { stdout, org, flags, setContext } = options;
492+
const { stdout, stderr, org, flags, setContext } = options;
477493
// Encode sort + query in context key so cursors from different searches don't collide.
478494
const escapedQuery = flags.query
479495
? escapeContextKeyValue(flags.query)
480496
: undefined;
481-
const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${escapedQuery ? `|q:${escapedQuery}` : ""}`;
497+
const escapedPeriod = escapeContextKeyValue(flags.period ?? "90d");
498+
const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}|period:${escapedPeriod}${escapedQuery ? `|q:${escapedQuery}` : ""}`;
482499
const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey);
483500

484501
setContext([org], []);
485502

486-
const { issues, nextCursor } = await fetchOrgAllIssues(org, flags, cursor);
503+
const { issues, nextCursor } = await withProgress(
504+
{ stderr, message: "Fetching issues..." },
505+
(setMessage) =>
506+
fetchOrgAllIssues(org, flags, cursor, (fetched, limit) =>
507+
setMessage(`Fetching issues... ${fetched}/${limit}`)
508+
)
509+
);
487510

488511
if (nextCursor) {
489512
setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor);
@@ -574,14 +597,31 @@ async function handleResolvedTargets(
574597
throw new ContextError("Organization and project", USAGE_HINT);
575598
}
576599

577-
const results = await Promise.all(
578-
targets.map((t) =>
579-
fetchIssuesForTarget(t, {
580-
query: flags.query,
581-
limit: flags.limit,
582-
sort: flags.sort,
583-
})
584-
)
600+
const results = await withProgress(
601+
{ stderr, message: "Fetching issues..." },
602+
(setMessage) => {
603+
// Track per-target previous counts to compute deltas — onPage reports the
604+
// running total for each target, not increments, so we need the previous
605+
// value to derive how many new items arrived per callback.
606+
let totalFetched = 0;
607+
const prevFetched = new Array<number>(targets.length).fill(0);
608+
const totalLimit = flags.limit * targets.length;
609+
return Promise.all(
610+
targets.map((t, i) =>
611+
fetchIssuesForTarget(t, {
612+
query: flags.query,
613+
limit: flags.limit,
614+
sort: flags.sort,
615+
statsPeriod: flags.period,
616+
onPage: (fetched) => {
617+
totalFetched += fetched - (prevFetched[i] ?? 0);
618+
prevFetched[i] = fetched;
619+
setMessage(`Fetching issues... ${totalFetched}/${totalLimit}`);
620+
},
621+
})
622+
)
623+
);
624+
}
585625
);
586626

587627
const validResults: IssueListResult[] = [];
@@ -715,7 +755,9 @@ export const listCommand = buildListCommand("issue", {
715755
"The --limit flag specifies the number of results to fetch per project (max 1000). " +
716756
"When the limit exceeds the API page size (100), multiple requests are made " +
717757
"automatically. Use --cursor to paginate through larger result sets. " +
718-
"Note: --cursor resumes from a single page to keep the cursor chain intact.",
758+
"Note: --cursor resumes from a single page to keep the cursor chain intact.\n\n" +
759+
"By default, only issues with activity in the last 90 days are shown. " +
760+
"Use --period to adjust (e.g. --period 24h, --period 14d).",
719761
},
720762
parameters: {
721763
positional: LIST_TARGET_POSITIONAL,
@@ -733,6 +775,12 @@ export const listCommand = buildListCommand("issue", {
733775
brief: "Sort by: date, new, freq, user",
734776
default: "date" as const,
735777
},
778+
period: {
779+
kind: "parsed",
780+
parse: String,
781+
brief: "Time period for issue activity (e.g. 24h, 14d, 90d)",
782+
default: "90d",
783+
},
736784
json: LIST_JSON_FLAG,
737785
cursor: {
738786
kind: "parsed",
@@ -757,7 +805,7 @@ export const listCommand = buildListCommand("issue", {
757805
optional: true,
758806
},
759807
},
760-
aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort" },
808+
aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort", t: "period" },
761809
},
762810
async func(
763811
this: SentryContext,
@@ -799,6 +847,7 @@ export const listCommand = buildListCommand("issue", {
799847
"org-all": (ctx) =>
800848
handleOrgAllIssues({
801849
stdout: ctx.stdout,
850+
stderr,
802851
org: ctx.parsed.org,
803852
flags,
804853
setContext,

src/lib/api-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,8 @@ export async function listIssuesAllPages(
10711071
limit: number;
10721072
sort?: "date" | "new" | "freq" | "user";
10731073
statsPeriod?: string;
1074+
/** Called after each page is fetched. Useful for progress indicators. */
1075+
onPage?: (fetched: number, limit: number) => void;
10741076
}
10751077
): Promise<IssuesPage> {
10761078
if (options.limit < 1) {
@@ -1095,6 +1097,7 @@ export async function listIssuesAllPages(
10951097
});
10961098

10971099
allResults.push(...response.data);
1100+
options.onPage?.(Math.min(allResults.length, options.limit), options.limit);
10981101

10991102
// Stop if we've reached the requested limit or there are no more pages
11001103
if (allResults.length >= options.limit || !response.nextCursor) {

src/lib/formatters/human.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* Follows gh cli patterns for alignment and presentation.
66
*/
77

8+
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
9+
import * as Sentry from "@sentry/bun";
810
import prettyMs from "pretty-ms";
911
import type {
1012
Breadcrumb,
@@ -313,6 +315,52 @@ const COL_SEEN = 10;
313315
/** Width for the FIXABILITY column (longest value "high(100%)" = 10) */
314316
const COL_FIX = 10;
315317

318+
/** Quantifier suffixes indexed by groups of 3 digits (K=10^3, M=10^6, …, E=10^18) */
319+
const QUANTIFIERS = ["", "K", "M", "B", "T", "P", "E"];
320+
321+
/**
322+
* Abbreviate large numbers to fit within {@link COL_COUNT} characters.
323+
* Uses K/M/B/T/P/E suffixes up to 10^18 (exa).
324+
*
325+
* The decimal is only shown when the rounded value is < 100 (e.g. "12.3K",
326+
* "1.5M" but not "100M"). The result is always exactly COL_COUNT chars wide.
327+
*
328+
* Note: `Number(raw)` loses precision above `Number.MAX_SAFE_INTEGER`
329+
* (~9P / 9×10^15), which is far beyond any realistic Sentry event count.
330+
*
331+
* Examples: 999 → " 999", 12345 → "12.3K", 150000 → " 150K", 1500000 → "1.5M"
332+
*/
333+
function abbreviateCount(raw: string): string {
334+
const n = Number(raw);
335+
if (Number.isNaN(n)) {
336+
// Non-numeric input: use a placeholder rather than passing through an
337+
// arbitrarily wide string that would break column alignment
338+
Sentry.logger.warn(`Unexpected non-numeric issue count: ${raw}`);
339+
return "?".padStart(COL_COUNT);
340+
}
341+
if (raw.length <= COL_COUNT) {
342+
return raw.padStart(COL_COUNT);
343+
}
344+
const tier = Math.min(Math.floor(Math.log10(n) / 3), QUANTIFIERS.length - 1);
345+
const suffix = QUANTIFIERS[tier] ?? "";
346+
const scaled = n / 10 ** (tier * 3);
347+
// Only show decimal when it adds information — compare the rounded value to avoid
348+
// "100.0K" when scaled is e.g. 99.95 (toFixed(1) rounds up to "100.0")
349+
const rounded1dp = Number(scaled.toFixed(1));
350+
if (rounded1dp < 100) {
351+
return `${rounded1dp.toFixed(1)}${suffix}`.padStart(COL_COUNT);
352+
}
353+
const rounded = Math.round(scaled);
354+
// Promote to next tier if rounding produces >= 1000 (e.g. 999.95K → "1.0M")
355+
if (rounded >= 1000 && tier < QUANTIFIERS.length - 1) {
356+
const nextSuffix = QUANTIFIERS[tier + 1] ?? "";
357+
return `${(rounded / 1000).toFixed(1)}${nextSuffix}`.padStart(COL_COUNT);
358+
}
359+
// At max tier with no promotion available: cap at 999 to guarantee COL_COUNT width
360+
// (numbers > 10^21 are unreachable in practice for Sentry event counts)
361+
return `${Math.min(rounded, 999)}${suffix}`.padStart(COL_COUNT);
362+
}
363+
316364
/** Column where title starts in single-project mode (no ALIAS column) */
317365
const TITLE_START_COL =
318366
COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2 + COL_FIX + 2;
@@ -582,7 +630,7 @@ export function formatIssueRow(
582630
const rawLen = getShortIdDisplayLength(issue.shortId);
583631
const shortIdPadding = " ".repeat(Math.max(0, COL_SHORT_ID - rawLen));
584632
const shortId = `${formattedShortId}${shortIdPadding}`;
585-
const count = `${issue.count}`.padStart(COL_COUNT);
633+
const count = abbreviateCount(`${issue.count}`);
586634
const seen = formatRelativeTime(issue.lastSeen);
587635

588636
// Fixability column (color applied after padding to preserve alignment)

0 commit comments

Comments
 (0)