Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ List issues in a project
- `-q, --query <value> - Search query (Sentry search syntax)`
- `-n, --limit <value> - Maximum number of issues to list - (default: "25")`
- `-s, --sort <value> - Sort by: date, new, freq, user - (default: "date")`
- `-t, --period <value> - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")`
- `--json - Output JSON`
- `-c, --cursor <value> - Pagination cursor — only for <org>/ mode (use "last" to continue)`

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

Expand Down
81 changes: 65 additions & 16 deletions src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
type ListCommandMeta,
type ModeHandler,
} from "../../lib/org-list.js";
import { withProgress } from "../../lib/polling.js";
import {
type ResolvedTarget,
resolveAllTargets,
Expand All @@ -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;
};
Expand Down Expand Up @@ -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<FetchResult> {
try {
const { issues } = await listIssuesAllPages(
Expand Down Expand Up @@ -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;
}

Expand All @@ -434,8 +445,9 @@ function nextPageHint(org: string, flags: ListFlags): string {
*/
async function fetchOrgAllIssues(
org: string,
flags: Pick<ListFlags, "query" | "limit" | "sort">,
cursor: string | undefined
flags: Pick<ListFlags, "query" | "limit" | "sort" | "period">,
cursor: string | undefined,
onPage?: (fetched: number, limit: number) => void
): Promise<IssuesPage> {
// When resuming with --cursor, fetch a single page so the cursor chain stays intact.
if (cursor) {
Expand All @@ -445,6 +457,7 @@ async function fetchOrgAllIssues(
cursor,
perPage,
sort: flags.sort,
statsPeriod: flags.period,
});
return { issues: response.data, nextCursor: response.nextCursor };
}
Expand All @@ -454,13 +467,16 @@ async function fetchOrgAllIssues(
query: flags.query,
limit: flags.limit,
sort: flags.sort,
statsPeriod: flags.period,
onPage,
});
return { issues, nextCursor };
}

/** Options for {@link handleOrgAllIssues}. */
type OrgAllIssuesOptions = {
stdout: Writer;
stderr: Writer;
org: string;
flags: ListFlags;
setContext: (orgs: string[], projects: string[]) => void;
Expand All @@ -473,17 +489,24 @@ type OrgAllIssuesOptions = {
* never accidentally reused.
*/
async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
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);
Expand Down Expand Up @@ -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<number>(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[] = [];
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -799,6 +847,7 @@ export const listCommand = buildListCommand("issue", {
"org-all": (ctx) =>
handleOrgAllIssues({
stdout: ctx.stdout,
stderr,
org: ctx.parsed.org,
flags,
setContext,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IssuesPage> {
if (options.limit < 1) {
Expand All @@ -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) {
Expand Down
50 changes: 49 additions & 1 deletion src/lib/formatters/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Count abbreviation skips exactly 10k

Low Severity

abbreviateCount decides whether to abbreviate based on raw.length <= COL_COUNT, so a count of 10000 (exactly 10k) is not abbreviated even though the feature intent is to abbreviate counts ≥10k. This creates inconsistent formatting right at the threshold.

Fix in Cursor Fix in Web

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;
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading