Skip to content

Commit 8dad881

Browse files
committed
feat(issue-list): add --period flag, pagination progress, and count abbreviation
- Add --period flag (default 90d) so the CLI matches the UI time window instead of the API's implicit 14d default; alias -p - Show an animated spinner on stderr (⠋⠙⠹… braille frames, 50ms / 20fps) when auto-paginating beyond 100 issues, updating with Fetching issues... N/limit; suppressed in JSON mode - Abbreviate large event counts (≥10k) with K/M/B suffixes so the COUNT column stays at 5 chars and never causes a layout shift - Add withProgress<T>() to polling.ts, sharing spinner infrastructure (frames, truncation, interval) with the Seer explain/plan commands - Expose onPage callback in listIssuesAllPages for per-page progress hooks
1 parent 30faf6f commit 8dad881

File tree

4 files changed

+169
-19
lines changed

4 files changed

+169
-19
lines changed

src/commands/issue/list.ts

Lines changed: 72 additions & 15 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,7 +489,7 @@ 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)
@@ -483,7 +499,22 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
483499

484500
setContext([org], []);
485501

486-
const { issues, nextCursor } = await fetchOrgAllIssues(org, flags, cursor);
502+
const shouldShowProgress = flags.limit > API_MAX_PER_PAGE && !flags.json;
503+
const { issues, nextCursor } = await withProgress(
504+
{
505+
stderr,
506+
message: "Fetching issues...",
507+
json: !shouldShowProgress,
508+
},
509+
(setMessage) => {
510+
const onPage = shouldShowProgress
511+
? (fetched: number, limit: number) => {
512+
setMessage(`Fetching issues... ${fetched}/${limit}`);
513+
}
514+
: undefined;
515+
return fetchOrgAllIssues(org, flags, cursor, onPage);
516+
}
517+
);
487518

488519
if (nextCursor) {
489520
setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor);
@@ -574,14 +605,31 @@ async function handleResolvedTargets(
574605
throw new ContextError("Organization and project", USAGE_HINT);
575606
}
576607

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-
)
608+
const shouldShowProgress = flags.limit > API_MAX_PER_PAGE && !flags.json;
609+
const results = await withProgress(
610+
{
611+
stderr,
612+
message: "Fetching issues...",
613+
json: !shouldShowProgress,
614+
},
615+
(setMessage) => {
616+
const onPage = shouldShowProgress
617+
? (fetched: number, limit: number) => {
618+
setMessage(`Fetching issues... ${fetched}/${limit}`);
619+
}
620+
: undefined;
621+
return Promise.all(
622+
targets.map((t) =>
623+
fetchIssuesForTarget(t, {
624+
query: flags.query,
625+
limit: flags.limit,
626+
sort: flags.sort,
627+
statsPeriod: flags.period,
628+
onPage,
629+
})
630+
)
631+
);
632+
}
585633
);
586634

587635
const validResults: IssueListResult[] = [];
@@ -715,7 +763,9 @@ export const listCommand = buildListCommand("issue", {
715763
"The --limit flag specifies the number of results to fetch per project (max 1000). " +
716764
"When the limit exceeds the API page size (100), multiple requests are made " +
717765
"automatically. Use --cursor to paginate through larger result sets. " +
718-
"Note: --cursor resumes from a single page to keep the cursor chain intact.",
766+
"Note: --cursor resumes from a single page to keep the cursor chain intact.\n\n" +
767+
"By default, only issues with activity in the last 90 days are shown. " +
768+
"Use --period to adjust (e.g. --period 24h, --period 14d).",
719769
},
720770
parameters: {
721771
positional: LIST_TARGET_POSITIONAL,
@@ -733,6 +783,12 @@ export const listCommand = buildListCommand("issue", {
733783
brief: "Sort by: date, new, freq, user",
734784
default: "date" as const,
735785
},
786+
period: {
787+
kind: "parsed",
788+
parse: String,
789+
brief: "Time period for issue activity (e.g. 24h, 14d, 90d)",
790+
default: "90d",
791+
},
736792
json: LIST_JSON_FLAG,
737793
cursor: {
738794
kind: "parsed",
@@ -757,7 +813,7 @@ export const listCommand = buildListCommand("issue", {
757813
optional: true,
758814
},
759815
},
760-
aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort" },
816+
aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort", t: "period" },
761817
},
762818
async func(
763819
this: SentryContext,
@@ -799,6 +855,7 @@ export const listCommand = buildListCommand("issue", {
799855
"org-all": (ctx) =>
800856
handleOrgAllIssues({
801857
stdout: ctx.stdout,
858+
stderr,
802859
org: ctx.parsed.org,
803860
flags,
804861
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?.(allResults.length, 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: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,28 @@ const COL_SEEN = 10;
313313
/** Width for the FIXABILITY column (longest value "high(100%)" = 10) */
314314
const COL_FIX = 10;
315315

316+
/**
317+
* Abbreviate large numbers to fit within {@link COL_COUNT} characters.
318+
* Uses K/M/B suffixes for thousands/millions/billions.
319+
*
320+
* Examples: 999 → "999", 1200 → "1.2K", 15000 → " 15K", 1500000 → "1.5M"
321+
*/
322+
function abbreviateCount(raw: string): string {
323+
const n = Number(raw);
324+
if (Number.isNaN(n) || n < 10_000) {
325+
return raw.padStart(COL_COUNT);
326+
}
327+
let abbreviated: string;
328+
if (n >= 1_000_000_000) {
329+
abbreviated = `${(n / 1_000_000_000).toFixed(n >= 10_000_000_000 ? 0 : 1)}B`;
330+
} else if (n >= 1_000_000) {
331+
abbreviated = `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
332+
} else {
333+
abbreviated = `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}K`;
334+
}
335+
return abbreviated.padStart(COL_COUNT);
336+
}
337+
316338
/** Column where title starts in single-project mode (no ALIAS column) */
317339
const TITLE_START_COL =
318340
COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2 + COL_FIX + 2;
@@ -582,7 +604,7 @@ export function formatIssueRow(
582604
const rawLen = getShortIdDisplayLength(issue.shortId);
583605
const shortIdPadding = " ".repeat(Math.max(0, COL_SHORT_ID - rawLen));
584606
const shortId = `${formattedShortId}${shortIdPadding}`;
585-
const count = `${issue.count}`.padStart(COL_COUNT);
607+
const count = abbreviateCount(`${issue.count}`);
586608
const seen = formatRelativeTime(issue.lastSeen);
587609

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

src/lib/polling.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import {
1414
/** Default polling interval in milliseconds */
1515
const DEFAULT_POLL_INTERVAL_MS = 1000;
1616

17-
/** Animation interval for spinner updates (independent of polling) */
18-
const ANIMATION_INTERVAL_MS = 80;
17+
/** Animation interval for spinner updates — 50ms gives 20fps, matching the ora/inquirer standard */
18+
const ANIMATION_INTERVAL_MS = 50;
1919

2020
/** Default timeout in milliseconds (6 minutes) */
2121
const DEFAULT_TIMEOUT_MS = 360_000;
@@ -49,7 +49,7 @@ export type PollOptions<T> = {
4949
*
5050
* Polls the fetchState function until shouldStop returns true or timeout is reached.
5151
* Displays an animated spinner with progress messages when not in JSON mode.
52-
* Animation runs at 80ms intervals independently of polling frequency.
52+
* Animation runs at 50ms intervals (20fps) independently of polling frequency.
5353
*
5454
* @typeParam T - The type of state being polled
5555
* @param options - Polling configuration
@@ -121,3 +121,71 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
121121
}
122122
}
123123
}
124+
125+
/**
126+
* Options for {@link withProgress}.
127+
*/
128+
export type WithProgressOptions = {
129+
/** Output stream for progress */
130+
stderr: Writer;
131+
/** Initial spinner message */
132+
message: string;
133+
/** Suppress progress output (JSON mode) */
134+
json?: boolean;
135+
};
136+
137+
/**
138+
* Run an async operation with an animated spinner on stderr.
139+
*
140+
* The spinner uses the same braille frames as the Seer polling spinner,
141+
* giving a consistent look across all CLI commands.
142+
*
143+
* The callback receives a `setMessage` function to update the displayed
144+
* message as work progresses (e.g. to show page counts during pagination).
145+
* Progress is automatically cleared when the operation completes.
146+
*
147+
* @param options - Spinner configuration
148+
* @param fn - Async operation to run; receives `setMessage` to update the displayed text
149+
* @returns The value returned by `fn`
150+
*
151+
* @example
152+
* ```typescript
153+
* const result = await withProgress(
154+
* { stderr, message: "Fetching issues...", json },
155+
* async (setMessage) => {
156+
* const data = await fetchWithPages({
157+
* onPage: (fetched, total) => setMessage(`Fetching issues... ${fetched}/${total}`),
158+
* });
159+
* return data;
160+
* }
161+
* );
162+
* ```
163+
*/
164+
export async function withProgress<T>(
165+
options: WithProgressOptions,
166+
fn: (setMessage: (msg: string) => void) => Promise<T>
167+
): Promise<T> {
168+
const { stderr, json = false } = options;
169+
let currentMessage = options.message;
170+
let tick = 0;
171+
172+
let animationTimer: Timer | undefined;
173+
if (!json) {
174+
animationTimer = setInterval(() => {
175+
const display = truncateProgressMessage(currentMessage);
176+
stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`);
177+
tick += 1;
178+
}, ANIMATION_INTERVAL_MS);
179+
}
180+
181+
try {
182+
return await fn((msg) => {
183+
currentMessage = msg;
184+
});
185+
} finally {
186+
if (animationTimer) {
187+
clearInterval(animationTimer);
188+
stderr.write("\r\x1b[K");
189+
}
190+
}
191+
}

0 commit comments

Comments
 (0)