Skip to content
Merged
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
38 changes: 33 additions & 5 deletions src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,45 @@ function shouldCollapseStats(json: boolean): boolean {
return !willShowTrend();
}

/**
* Fields that depend on the `lifetime` API data. When `collapse=lifetime`
* is sent, the server omits these from the list response. See #969.
*/
const LIFETIME_FIELDS = new Set([
"count",
"userCount",
"firstSeen",
"lastSeen",
]);

/**
* Build the collapse and groupStatsPeriod options for issue list API calls.
*
* When stats are collapsed, groupStatsPeriod is omitted (undefined) since
* the server won't compute stats anyway. This avoids wasted server-side
* processing and makes the request intent explicit.
*
* Lifetime is only collapsed in JSON mode when explicit `--fields` are
* provided and none of them are lifetime-dependent (`count`, `userCount`,
* `firstSeen`, `lastSeen`). Human output always needs these for the
* EVENTS, USERS, SEEN, and AGE columns.
*/
function buildListApiOptions(json: boolean): ListApiOptions {
function buildListApiOptions(json: boolean, fields?: string[]): ListApiOptions {
const collapseStats = shouldCollapseStats(json);
// Collapse lifetime only when in JSON mode with explicit --fields that
// don't include any lifetime-dependent field. Human output always needs
// these (EVENTS, USERS, SEEN, AGE columns), and JSON without --fields
// returns all fields.
const collapseLifetime =
json &&
fields !== undefined &&
fields.length > 0 &&
!fields.some((f) => LIFETIME_FIELDS.has(f));
return {
collapse: buildIssueListCollapse({ shouldCollapseStats: collapseStats }),
collapse: buildIssueListCollapse({
shouldCollapseStats: collapseStats,
shouldCollapseLifetime: collapseLifetime,
}),
groupStatsPeriod: collapseStats ? undefined : "auto",
};
}
Expand Down Expand Up @@ -870,14 +898,14 @@ function prevPageHint(org: string, flags: ListFlags): string {
*/
async function fetchOrgAllIssues(
org: string,
flags: Pick<ListFlags, "query" | "limit" | "sort" | "json">,
flags: Pick<ListFlags, "query" | "limit" | "sort" | "json" | "fields">,
timeRange: TimeRange,
options: {
cursor?: string;
onPage?: (fetched: number, limit: number) => void;
}
): Promise<IssuesPage> {
const apiOpts = buildListApiOptions(flags.json);
const apiOpts = buildListApiOptions(flags.json, flags.fields);
const timeParams = timeRangeToApiParams(timeRange);
const { cursor, onPage } = options;

Expand Down Expand Up @@ -1257,7 +1285,7 @@ async function handleResolvedTargets(
? `Fetching issues from ${targetCount} projects`
: "Fetching issues";

const apiOpts = buildListApiOptions(flags.json);
const apiOpts = buildListApiOptions(flags.json, flags.fields);

const { results, hasMore } = await withProgress(
{ message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json },
Expand Down
32 changes: 25 additions & 7 deletions src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type IssueSort = NonNullable<
* expensive Snuba/ClickHouse queries on the backend.
*
* - `'stats'` — time-series event counts (sparkline data)
* - `'lifetime'` — lifetime aggregate counts (count, userCount, firstSeen)
* - `'lifetime'` — lifetime aggregate sub-object AND top-level count/userCount/firstSeen/lastSeen on list endpoints
* - `'filtered'` — filtered aggregate counts
* - `'unhandled'` — unhandled event flag computation
* - `'base'` — base group fields (rarely useful to collapse)
Expand All @@ -59,9 +59,17 @@ export type IssueCollapseField = NonNullable<
/**
* Build the `collapse` parameter for issue list API calls.
*
* Always collapses fields the CLI never consumes in issue list:
* `filtered`, `lifetime`, `unhandled`. Conditionally collapses `stats`
* when sparklines won't be rendered (narrow terminal, non-TTY, or JSON).
* Always collapses `filtered` and `unhandled` — the CLI never consumes
* these in issue list views. Conditionally collapses `stats` when
* sparklines won't be rendered (narrow terminal, non-TTY, or JSON),
* and `lifetime` when the caller confirms the lifetime-dependent
* top-level fields (`count`, `userCount`, `firstSeen`, `lastSeen`)
* aren't needed.
*
* **Important:** Despite being documented as removing only the `lifetime`
* sub-object, `collapse=lifetime` also strips the top-level `count`,
* `userCount`, `firstSeen`, and `lastSeen` fields from list responses.
* Only collapse it when those fields are confirmed unnecessary. See #969.
*
* Matches the Sentry web UI's optimization: the initial page load sends
* `collapse=stats,unhandled` to skip expensive Snuba queries, fetching
Expand All @@ -70,12 +78,20 @@ export type IssueCollapseField = NonNullable<
* @param options - Context for determining what to collapse
* @param options.shouldCollapseStats - Whether stats data can be skipped
* (true when sparklines won't be shown: narrow terminal, non-TTY, --json)
* @param options.shouldCollapseLifetime - Whether lifetime data can be skipped.
* Defaults to `false` because most output paths need `count`/`userCount`/
* `firstSeen`/`lastSeen`. Only set to `true` when `--json --fields` omits
* all lifetime-dependent fields.
* @returns Array of fields to collapse
*/
export function buildIssueListCollapse(options: {
shouldCollapseStats: boolean;
shouldCollapseLifetime?: boolean;
}): IssueCollapseField[] {
const collapse: IssueCollapseField[] = ["filtered", "lifetime", "unhandled"];
const collapse: IssueCollapseField[] = ["filtered", "unhandled"];
if (options.shouldCollapseLifetime) {
collapse.push("lifetime");
}
if (options.shouldCollapseStats) {
collapse.push("stats");
}
Expand All @@ -90,8 +106,10 @@ export function buildIssueListCollapse(options: {
* in detail views (`issue view`, `issue explain`, `issue plan`).
* Collapsing these skips expensive Snuba queries, saving 100-300ms per request.
*
* Note: `count`, `userCount`, `firstSeen`, `lastSeen` are top-level fields
* and remain unaffected by collapsing.
* Note: On the **list** endpoint, `collapse=lifetime` strips top-level
* `count`, `userCount`, `firstSeen`, `lastSeen` (see #969). The detail
* endpoint preserves these fields regardless of collapse — safe to include
* `lifetime` here.
*/
export const ISSUE_DETAIL_COLLAPSE: IssueCollapseField[] = [
"stats",
Expand Down
122 changes: 120 additions & 2 deletions test/commands/issue/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type ListFlags = {
readonly period: TimeRange;
readonly json: boolean;
readonly cursor?: string;
readonly fields?: string[];
readonly fresh?: boolean;
readonly compact?: boolean;
};

/** Command function type extracted from loader result */
Expand Down Expand Up @@ -1110,7 +1113,7 @@ describe("issue list: collapse parameter optimization", () => {
advancePaginationStateSpy.mockRestore();
});

test("always collapses filtered, lifetime, unhandled in org-all mode", async () => {
test("always collapses filtered and unhandled in org-all mode", async () => {
listIssuesPaginatedSpy.mockResolvedValue({
data: [sampleIssue],
nextCursor: undefined,
Expand All @@ -1134,10 +1137,125 @@ describe("issue list: collapse parameter optimization", () => {
const options = callArgs?.[2] as Record<string, unknown> | undefined;
const collapse = options?.collapse as string[];
expect(collapse).toContain("filtered");
expect(collapse).toContain("lifetime");
expect(collapse).toContain("unhandled");
});

test("does not collapse lifetime in human mode (needed for EVENTS/USERS/SEEN/AGE)", async () => {
listIssuesPaginatedSpy.mockResolvedValue({
data: [sampleIssue],
nextCursor: undefined,
});

const orgAllFunc = (await listCommand.loader()) as unknown as (
this: unknown,
flags: Record<string, unknown>,
target?: string
) => Promise<void>;

const { context } = createOrgAllContext();
await orgAllFunc.call(
context,
{ limit: 10, sort: "date", period: parsePeriod("90d"), json: false },
"my-org/"
);

expect(listIssuesPaginatedSpy).toHaveBeenCalled();
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
const options = callArgs?.[2] as Record<string, unknown> | undefined;
const collapse = options?.collapse as string[];
expect(collapse).not.toContain("lifetime");
});

test("does not collapse lifetime in JSON mode without --fields", async () => {
listIssuesPaginatedSpy.mockResolvedValue({
data: [sampleIssue],
nextCursor: undefined,
});

const orgAllFunc = (await listCommand.loader()) as unknown as (
this: unknown,
flags: Record<string, unknown>,
target?: string
) => Promise<void>;

const { context } = createOrgAllContext();
await orgAllFunc.call(
context,
{ limit: 10, sort: "date", period: parsePeriod("90d"), json: true },
"my-org/"
);

expect(listIssuesPaginatedSpy).toHaveBeenCalled();
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
const options = callArgs?.[2] as Record<string, unknown> | undefined;
const collapse = options?.collapse as string[];
expect(collapse).not.toContain("lifetime");
});

test("does not collapse lifetime in JSON mode when --fields includes lifetime-dependent field", async () => {
listIssuesPaginatedSpy.mockResolvedValue({
data: [sampleIssue],
nextCursor: undefined,
});

const orgAllFunc = (await listCommand.loader()) as unknown as (
this: unknown,
flags: Record<string, unknown>,
target?: string
) => Promise<void>;

const { context } = createOrgAllContext();
await orgAllFunc.call(
context,
{
limit: 10,
sort: "date",
period: parsePeriod("90d"),
json: true,
fields: ["shortId", "title", "count"],
},
"my-org/"
);

expect(listIssuesPaginatedSpy).toHaveBeenCalled();
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
const options = callArgs?.[2] as Record<string, unknown> | undefined;
const collapse = options?.collapse as string[];
expect(collapse).not.toContain("lifetime");
});

test("collapses lifetime in JSON mode when --fields omits all lifetime-dependent fields", async () => {
listIssuesPaginatedSpy.mockResolvedValue({
data: [sampleIssue],
nextCursor: undefined,
});

const orgAllFunc = (await listCommand.loader()) as unknown as (
this: unknown,
flags: Record<string, unknown>,
target?: string
) => Promise<void>;

const { context } = createOrgAllContext();
await orgAllFunc.call(
context,
{
limit: 10,
sort: "date",
period: parsePeriod("90d"),
json: true,
fields: ["shortId", "title"],
},
"my-org/"
);

expect(listIssuesPaginatedSpy).toHaveBeenCalled();
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
const options = callArgs?.[2] as Record<string, unknown> | undefined;
const collapse = options?.collapse as string[];
expect(collapse).toContain("lifetime");
});

test("collapses stats in JSON mode", async () => {
listIssuesPaginatedSpy.mockResolvedValue({
data: [sampleIssue],
Expand Down
Loading
Loading