diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index c756af6d..c7e6b769 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -204,6 +204,7 @@ List issues in a project - `-q, --query - Search query (Sentry search syntax)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` +- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor — only for / mode (use "last" to continue)` @@ -594,6 +595,7 @@ List issues in a project - `-q, --query - Search query (Sentry search syntax)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` +- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor — only for / mode (use "last" to continue)` diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 49aa7f3d..796149b4 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -54,6 +54,7 @@ import { type ListCommandMeta, type ModeHandler, } from "../../lib/org-list.js"; +import { withProgress } from "../../lib/polling.js"; import { type ResolvedTarget, resolveAllTargets, @@ -72,6 +73,7 @@ type ListFlags = { readonly query?: string; readonly limit: number; readonly sort: "date" | "new" | "freq" | "user"; + readonly period: string; readonly json: boolean; readonly cursor?: string; }; @@ -391,7 +393,13 @@ async function resolveTargetsFromParsedArg( */ async function fetchIssuesForTarget( target: ResolvedTarget, - options: { query?: string; limit: number; sort: SortValue } + options: { + query?: string; + limit: number; + sort: SortValue; + statsPeriod?: string; + onPage?: (fetched: number, limit: number) => void; + } ): Promise { try { const { issues } = await listIssuesAllPages( @@ -423,6 +431,9 @@ function nextPageHint(org: string, flags: ListFlags): string { if (flags.query) { parts.push(`-q "${flags.query}"`); } + if (flags.period !== "90d") { + parts.push(`-t ${flags.period}`); + } return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; } @@ -434,8 +445,9 @@ function nextPageHint(org: string, flags: ListFlags): string { */ async function fetchOrgAllIssues( org: string, - flags: Pick, - cursor: string | undefined + flags: Pick, + cursor: string | undefined, + onPage?: (fetched: number, limit: number) => void ): Promise { // When resuming with --cursor, fetch a single page so the cursor chain stays intact. if (cursor) { @@ -445,6 +457,7 @@ async function fetchOrgAllIssues( cursor, perPage, sort: flags.sort, + statsPeriod: flags.period, }); return { issues: response.data, nextCursor: response.nextCursor }; } @@ -454,6 +467,8 @@ async function fetchOrgAllIssues( query: flags.query, limit: flags.limit, sort: flags.sort, + statsPeriod: flags.period, + onPage, }); return { issues, nextCursor }; } @@ -461,6 +476,7 @@ async function fetchOrgAllIssues( /** Options for {@link handleOrgAllIssues}. */ type OrgAllIssuesOptions = { stdout: Writer; + stderr: Writer; org: string; flags: ListFlags; setContext: (orgs: string[], projects: string[]) => void; @@ -473,17 +489,24 @@ type OrgAllIssuesOptions = { * never accidentally reused. */ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise { - const { stdout, org, flags, setContext } = options; + const { stdout, stderr, org, flags, setContext } = options; // Encode sort + query in context key so cursors from different searches don't collide. const escapedQuery = flags.query ? escapeContextKeyValue(flags.query) : undefined; - const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${escapedQuery ? `|q:${escapedQuery}` : ""}`; + const escapedPeriod = escapeContextKeyValue(flags.period ?? "90d"); + const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}|period:${escapedPeriod}${escapedQuery ? `|q:${escapedQuery}` : ""}`; const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); setContext([org], []); - const { issues, nextCursor } = await fetchOrgAllIssues(org, flags, cursor); + const { issues, nextCursor } = await withProgress( + { stderr, message: "Fetching issues..." }, + (setMessage) => + fetchOrgAllIssues(org, flags, cursor, (fetched, limit) => + setMessage(`Fetching issues... ${fetched}/${limit}`) + ) + ); if (nextCursor) { setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor); @@ -574,14 +597,31 @@ async function handleResolvedTargets( throw new ContextError("Organization and project", USAGE_HINT); } - const results = await Promise.all( - targets.map((t) => - fetchIssuesForTarget(t, { - query: flags.query, - limit: flags.limit, - sort: flags.sort, - }) - ) + const results = await withProgress( + { stderr, message: "Fetching issues..." }, + (setMessage) => { + // Track per-target previous counts to compute deltas — onPage reports the + // running total for each target, not increments, so we need the previous + // value to derive how many new items arrived per callback. + let totalFetched = 0; + const prevFetched = new Array(targets.length).fill(0); + const totalLimit = flags.limit * targets.length; + return Promise.all( + targets.map((t, i) => + fetchIssuesForTarget(t, { + query: flags.query, + limit: flags.limit, + sort: flags.sort, + statsPeriod: flags.period, + onPage: (fetched) => { + totalFetched += fetched - (prevFetched[i] ?? 0); + prevFetched[i] = fetched; + setMessage(`Fetching issues... ${totalFetched}/${totalLimit}`); + }, + }) + ) + ); + } ); const validResults: IssueListResult[] = []; @@ -715,7 +755,9 @@ export const listCommand = buildListCommand("issue", { "The --limit flag specifies the number of results to fetch per project (max 1000). " + "When the limit exceeds the API page size (100), multiple requests are made " + "automatically. Use --cursor to paginate through larger result sets. " + - "Note: --cursor resumes from a single page to keep the cursor chain intact.", + "Note: --cursor resumes from a single page to keep the cursor chain intact.\n\n" + + "By default, only issues with activity in the last 90 days are shown. " + + "Use --period to adjust (e.g. --period 24h, --period 14d).", }, parameters: { positional: LIST_TARGET_POSITIONAL, @@ -733,6 +775,12 @@ export const listCommand = buildListCommand("issue", { brief: "Sort by: date, new, freq, user", default: "date" as const, }, + period: { + kind: "parsed", + parse: String, + brief: "Time period for issue activity (e.g. 24h, 14d, 90d)", + default: "90d", + }, json: LIST_JSON_FLAG, cursor: { kind: "parsed", @@ -757,7 +805,7 @@ export const listCommand = buildListCommand("issue", { optional: true, }, }, - aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort" }, + aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort", t: "period" }, }, async func( this: SentryContext, @@ -799,6 +847,7 @@ export const listCommand = buildListCommand("issue", { "org-all": (ctx) => handleOrgAllIssues({ stdout: ctx.stdout, + stderr, org: ctx.parsed.org, flags, setContext, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index c2b16ad6..220e8213 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1071,6 +1071,8 @@ export async function listIssuesAllPages( limit: number; sort?: "date" | "new" | "freq" | "user"; statsPeriod?: string; + /** Called after each page is fetched. Useful for progress indicators. */ + onPage?: (fetched: number, limit: number) => void; } ): Promise { if (options.limit < 1) { @@ -1095,6 +1097,7 @@ export async function listIssuesAllPages( }); allResults.push(...response.data); + options.onPage?.(Math.min(allResults.length, options.limit), options.limit); // Stop if we've reached the requested limit or there are no more pages if (allResults.length >= options.limit || !response.nextCursor) { diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 7850b55e..365e103f 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -5,6 +5,8 @@ * Follows gh cli patterns for alignment and presentation. */ +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import +import * as Sentry from "@sentry/bun"; import prettyMs from "pretty-ms"; import type { Breadcrumb, @@ -313,6 +315,52 @@ const COL_SEEN = 10; /** Width for the FIXABILITY column (longest value "high(100%)" = 10) */ const COL_FIX = 10; +/** Quantifier suffixes indexed by groups of 3 digits (K=10^3, M=10^6, …, E=10^18) */ +const QUANTIFIERS = ["", "K", "M", "B", "T", "P", "E"]; + +/** + * Abbreviate large numbers to fit within {@link COL_COUNT} characters. + * Uses K/M/B/T/P/E suffixes up to 10^18 (exa). + * + * The decimal is only shown when the rounded value is < 100 (e.g. "12.3K", + * "1.5M" but not "100M"). The result is always exactly COL_COUNT chars wide. + * + * Note: `Number(raw)` loses precision above `Number.MAX_SAFE_INTEGER` + * (~9P / 9×10^15), which is far beyond any realistic Sentry event count. + * + * Examples: 999 → " 999", 12345 → "12.3K", 150000 → " 150K", 1500000 → "1.5M" + */ +function abbreviateCount(raw: string): string { + const n = Number(raw); + if (Number.isNaN(n)) { + // Non-numeric input: use a placeholder rather than passing through an + // arbitrarily wide string that would break column alignment + Sentry.logger.warn(`Unexpected non-numeric issue count: ${raw}`); + return "?".padStart(COL_COUNT); + } + if (raw.length <= COL_COUNT) { + return raw.padStart(COL_COUNT); + } + const tier = Math.min(Math.floor(Math.log10(n) / 3), QUANTIFIERS.length - 1); + const suffix = QUANTIFIERS[tier] ?? ""; + const scaled = n / 10 ** (tier * 3); + // Only show decimal when it adds information — compare the rounded value to avoid + // "100.0K" when scaled is e.g. 99.95 (toFixed(1) rounds up to "100.0") + const rounded1dp = Number(scaled.toFixed(1)); + if (rounded1dp < 100) { + return `${rounded1dp.toFixed(1)}${suffix}`.padStart(COL_COUNT); + } + const rounded = Math.round(scaled); + // Promote to next tier if rounding produces >= 1000 (e.g. 999.95K → "1.0M") + if (rounded >= 1000 && tier < QUANTIFIERS.length - 1) { + const nextSuffix = QUANTIFIERS[tier + 1] ?? ""; + return `${(rounded / 1000).toFixed(1)}${nextSuffix}`.padStart(COL_COUNT); + } + // At max tier with no promotion available: cap at 999 to guarantee COL_COUNT width + // (numbers > 10^21 are unreachable in practice for Sentry event counts) + return `${Math.min(rounded, 999)}${suffix}`.padStart(COL_COUNT); +} + /** Column where title starts in single-project mode (no ALIAS column) */ const TITLE_START_COL = COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2 + COL_FIX + 2; @@ -582,7 +630,7 @@ export function formatIssueRow( const rawLen = getShortIdDisplayLength(issue.shortId); const shortIdPadding = " ".repeat(Math.max(0, COL_SHORT_ID - rawLen)); const shortId = `${formattedShortId}${shortIdPadding}`; - const count = `${issue.count}`.padStart(COL_COUNT); + const count = abbreviateCount(`${issue.count}`); const seen = formatRelativeTime(issue.lastSeen); // Fixability column (color applied after padding to preserve alignment) diff --git a/src/lib/polling.ts b/src/lib/polling.ts index 9bc6e6b4..aab2b041 100644 --- a/src/lib/polling.ts +++ b/src/lib/polling.ts @@ -14,8 +14,8 @@ import { /** Default polling interval in milliseconds */ const DEFAULT_POLL_INTERVAL_MS = 1000; -/** Animation interval for spinner updates (independent of polling) */ -const ANIMATION_INTERVAL_MS = 80; +/** Animation interval for spinner updates — 50ms gives 20fps, matching the ora/inquirer standard */ +const ANIMATION_INTERVAL_MS = 50; /** Default timeout in milliseconds (6 minutes) */ const DEFAULT_TIMEOUT_MS = 360_000; @@ -49,7 +49,7 @@ export type PollOptions = { * * Polls the fetchState function until shouldStop returns true or timeout is reached. * Displays an animated spinner with progress messages when not in JSON mode. - * Animation runs at 80ms intervals independently of polling frequency. + * Animation runs at 50ms intervals (20fps) independently of polling frequency. * * @typeParam T - The type of state being polled * @param options - Polling configuration @@ -83,26 +83,17 @@ export async function poll(options: PollOptions): Promise { } = options; const startTime = Date.now(); - let tick = 0; - let currentMessage = initialMessage; - - // Animation timer runs independently of polling for smooth spinner - let animationTimer: Timer | undefined; - if (!json) { - animationTimer = setInterval(() => { - const display = truncateProgressMessage(currentMessage); - stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`); - tick += 1; - }, ANIMATION_INTERVAL_MS); - } + const spinner = json ? null : startSpinner(stderr, initialMessage); try { while (Date.now() - startTime < timeoutMs) { const state = await fetchState(); if (state) { - // Update message for animation loop to display - currentMessage = getProgressMessage(state); + // Always call getProgressMessage (callers may rely on the callback + // being invoked), but only forward the result to the spinner. + const msg = getProgressMessage(state); + spinner?.setMessage(msg); if (shouldStop(state)) { return state; @@ -114,10 +105,96 @@ export async function poll(options: PollOptions): Promise { throw new Error(timeoutMessage); } finally { - // Clean up animation timer - if (animationTimer) { - clearInterval(animationTimer); + spinner?.stop(); + if (!json) { stderr.write("\n"); } } } + +/** + * Start an animated spinner that writes progress to stderr. + * + * Returns a controller with `setMessage` to update the displayed text + * and `stop` to halt the animation. + */ +function startSpinner( + stderr: Writer, + initialMessage: string +): { setMessage: (msg: string) => void; stop: () => void } { + let currentMessage = initialMessage; + let tick = 0; + let stopped = false; + + const scheduleFrame = () => { + if (stopped) { + return; + } + const display = truncateProgressMessage(currentMessage); + stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`); + tick += 1; + setTimeout(scheduleFrame, ANIMATION_INTERVAL_MS).unref(); + }; + scheduleFrame(); + + return { + setMessage: (msg: string) => { + currentMessage = msg; + }, + stop: () => { + stopped = true; + }, + }; +} + +/** + * Options for {@link withProgress}. + */ +export type WithProgressOptions = { + /** Output stream for progress */ + stderr: Writer; + /** Initial spinner message */ + message: string; +}; + +/** + * Run an async operation with an animated spinner on stderr. + * + * The spinner uses the same braille frames as the Seer polling spinner, + * giving a consistent look across all CLI commands. Progress output goes + * to stderr, so it never contaminates stdout (safe to use alongside JSON output). + * + * The callback receives a `setMessage` function to update the displayed + * message as work progresses (e.g. to show page counts during pagination). + * Progress is automatically cleared when the operation completes. + * + * @param options - Spinner configuration + * @param fn - Async operation to run; receives `setMessage` to update the displayed text + * @returns The value returned by `fn` + * + * @example + * ```typescript + * const result = await withProgress( + * { stderr, message: "Fetching issues..." }, + * async (setMessage) => { + * const data = await fetchWithPages({ + * onPage: (fetched, total) => setMessage(`Fetching issues... ${fetched}/${total}`), + * }); + * return data; + * } + * ); + * ``` + */ +export async function withProgress( + options: WithProgressOptions, + fn: (setMessage: (msg: string) => void) => Promise +): Promise { + const spinner = startSpinner(options.stderr, options.message); + + try { + return await fn(spinner.setMessage); + } finally { + spinner.stop(); + options.stderr.write("\r\x1b[K"); + } +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 10e58041..3a71c452 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -263,6 +263,8 @@ export function initSentry(enabled: boolean): Sentry.BunClient | undefined { (integration) => !EXCLUDED_INTEGRATIONS.has(integration.name) ), environment, + // Enable Sentry structured logs for non-exception telemetry (e.g., unexpected input warnings) + enableLogs: true, // Sample all events for CLI telemetry (low volume) tracesSampleRate: 1, sampleRate: 1,