diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index f13910f56..c5be1e075 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -7,10 +7,6 @@ import type { SentryContext } from "../../context.js"; import { buildProjectAliasMap } from "../../lib/alias.js"; -import { - fetchScopedCounts, - type ScopedCount, -} from "../../lib/api/issue-scoped-counts.js"; import { API_MAX_PER_PAGE, buildIssueListCollapse, @@ -254,15 +250,24 @@ function shouldCollapseStats(json: boolean, fields?: string[]): boolean { * Stats and lifetime are only collapsed in JSON mode with explicit `--fields` * that omit the corresponding dependent fields. Human output always requests * seen-stats data for the EVENTS, USERS, SEEN, and AGE columns. + * + * When the query needs query-scoped counts (see {@link shouldScopeCounts}), + * `filtered` is requested and `stats` is kept, because the server nests the + * `filtered` block under stats. See #1583. */ -function buildListApiOptions(json: boolean, fields?: string[]): ListApiOptions { - const collapseStats = shouldCollapseStats(json, fields); +function buildListApiOptions( + flags: Pick, +): ListApiOptions { + const scopeCounts = shouldScopeCounts(flags); + const collapseStats = + !scopeCounts && shouldCollapseStats(flags.json, flags.fields); const collapseLifetime = - json && shouldCollapseForFields(fields, LIFETIME_FIELDS); + flags.json && shouldCollapseForFields(flags.fields, LIFETIME_FIELDS); return { collapse: buildIssueListCollapse({ shouldCollapseStats: collapseStats, shouldCollapseLifetime: collapseLifetime, + shouldCollapseFiltered: !scopeCounts, }), groupStatsPeriod: collapseStats ? undefined : "auto", }; @@ -271,8 +276,8 @@ function buildListApiOptions(json: boolean, fields?: string[]): ListApiOptions { /** * Whether the seen-stats counts (`count`/`userCount`) are displayed for the * given flags. Human output always shows them; JSON only omits them when - * `--fields` collapses lifetime fields away. Used to skip the scoped-count - * refetch when the counts aren't shown anyway. See #1518. + * `--fields` collapses lifetime fields away. Used to skip requesting + * query-scoped counts when the counts aren't shown anyway. */ function countsShown(json: boolean, fields?: string[]): boolean { return !(json && shouldCollapseForFields(fields, LIFETIME_FIELDS)); @@ -284,8 +289,8 @@ function sortUsesCounts(sort: ListFlags["sort"]): boolean { } /** - * Whether a scoped-count refetch applies to the current flags: the query must - * filter on a count-unscoped key, and the counts must either be displayed or + * Whether to request query-scoped counts: the query must filter on a key the + * top-level seen-stats ignore, and the counts must either be displayed or * drive the `freq`/`user` ordering. */ function shouldScopeCounts( @@ -299,114 +304,26 @@ function shouldScopeCounts( } /** - * Group display rows by org slug so each events query runs against the right - * organization (org-all / multi-project modes mix orgs). Rows without a short - * ID are skipped — they can't be scoped via `issue:`. - */ -function groupRowsByOrg(rows: IssueTableRow[]): Map { - const byOrg = new Map(); - for (const row of rows) { - if (!row.issue.shortId) { - continue; - } - const group = byOrg.get(row.orgSlug); - if (group) { - group.push(row); - } else { - byOrg.set(row.orgSlug, [row]); - } - } - return byOrg; -} - -/** - * Overwrite each row's issue counts from the per-org scoped-count maps. - * Returns true when at least one count changed. - */ -function overwriteScopedCounts( - groups: { orgRows: IssueTableRow[]; scoped: Map }[], -): boolean { - let changed = false; - for (const { orgRows, scoped } of groups) { - for (const row of orgRows) { - const s = scoped.get(row.issue.shortId); - if (s) { - row.issue.count = String(s.count); - row.issue.userCount = s.userCount; - changed = true; - } - } - } - return changed; -} - -/** - * Replace each issue's unscoped list-endpoint counts (`count`/`userCount`) with - * filter-scoped counts from the events endpoint when the query filters on a key - * the issues-list seen-stats ignore (e.g. `release:`). The list endpoint reports - * period-wide totals across every value of that key, so a release-scoped triage - * silently over-reports; the events endpoint returns counts that match the - * filter. See getsentry/cli#1518. + * Replace each issue's period-wide `count`/`userCount` with the server's + * query-scoped `filtered` counts, when the response includes them. * - * Best-effort: any failure leaves the original counts untouched. When the sort - * depends on counts (`freq`/`user`), the display is re-ranked to follow the - * corrected numbers. Mutates `result` in place. + * The issues-list endpoint computes `filtered` seen-stats with the same + * event-level conditions (e.g. `release:`) that select and order the issues, + * but only when `filtered` is not collapsed (see {@link buildListApiOptions}). + * Promoting them right after each fetch keeps the displayed numbers, the + * multi-project merge order, and the `--limit` trim consistent with the + * query. The `filtered` block is dropped so the JSON output shape is + * unchanged. Mutates `issues` in place. See #1583. */ -async function applyScopedCounts( - result: IssueListResult, - flags: Pick, - timeRange: TimeRange, -): Promise { - try { - const rows = result.displayRows; - if (!(shouldScopeCounts(flags) && rows && rows.length > 0)) { - return; - } - const byOrg = groupRowsByOrg(rows); - if (byOrg.size === 0) { - return; - } - - const timeParams = timeRangeToApiParams(timeRange); - const groups = await Promise.all( - [...byOrg.entries()].map(async ([orgSlug, orgRows]) => { - const scoped = await fetchScopedCounts( - orgSlug, - orgRows.map((row) => ({ - shortId: row.issue.shortId, - projectId: parseProjectId(row.issue.project?.id), - })), - { - query: flags.query ?? "", - statsPeriod: timeParams.statsPeriod, - start: timeParams.start, - end: timeParams.end, - }, - ); - return { orgRows, scoped }; - }), - ); - - // Re-rank when the ordering follows the counts we just corrected. - if (overwriteScopedCounts(groups) && sortUsesCounts(flags.sort)) { - const cmp = getComparator(flags.sort); - result.items.sort(cmp); - rows.sort((a, b) => cmp(a.issue, b.issue)); +function applyQueryScopedCounts(issues: SentryIssue[]): SentryIssue[] { + for (const issue of issues) { + if (issue.filtered) { + issue.count = issue.filtered.count; + issue.userCount = issue.filtered.userCount; } - } catch (error) { - logger - .withTag("issue.list") - .debug(`Scoped-count refetch skipped: ${String(error)}`); - } -} - -/** Coerce an optional project ID (string from the API) to a number. */ -function parseProjectId(id: string | undefined): number | undefined { - if (id === undefined) { - return; + delete issue.filtered; } - const n = Number(id); - return Number.isFinite(n) ? n : undefined; + return issues; } /** @@ -603,7 +520,12 @@ async function fetchIssuesForTarget( end: options.end, }, ); - return { target, issues, hasMore: !!nextCursor, nextCursor }; + return { + target, + issues: applyQueryScopedCounts(issues), + hasMore: !!nextCursor, + nextCursor, + }; }); if (!result.ok) { @@ -836,7 +758,7 @@ async function fetchOrganizationIssues( onPage?: (fetched: number, limit: number) => void; }, ): Promise { - const apiOpts = buildListApiOptions(flags.json, flags.fields); + const apiOpts = buildListApiOptions(flags); const timeParams = timeRangeToApiParams(timeRange); const { cursor, onPage, projects } = options; @@ -853,7 +775,10 @@ async function fetchOrganizationIssues( groupStatsPeriod: apiOpts.groupStatsPeriod, collapse: apiOpts.collapse, }); - return { issues: response.data, nextCursor: response.nextCursor }; + return { + issues: applyQueryScopedCounts(response.data), + nextCursor: response.nextCursor, + }; } // No cursor — auto-paginate from the beginning via the shared helper. @@ -867,7 +792,7 @@ async function fetchOrganizationIssues( collapse: apiOpts.collapse, onPage, }); - return { issues, nextCursor }; + return { issues: applyQueryScopedCounts(issues), nextCursor }; } /** Options for {@link handleOrganizationIssues}. */ @@ -1340,7 +1265,7 @@ async function handleResolvedTargets( ? `Fetching issues from ${targetCount} projects` : "Fetching issues"; - const apiOpts = buildListApiOptions(flags.json, flags.fields); + const apiOpts = buildListApiOptions(flags); const { results, hasMore } = await withProgress( { message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json }, @@ -1886,12 +1811,6 @@ export const listCommand = buildListCommand("issue", { }, })) as IssueListResult; - // The issues-list endpoint's seen-stats (count/userCount) are period-wide - // totals that ignore query filters like release:. When such a filter is - // active, re-fetch filter-scoped counts from the events endpoint so the - // displayed numbers (and freq/user ordering) match the query. See #1518. - await applyScopedCounts(result, flags, timeRange); - const combinedHint = buildCombinedHint(result); yield new CommandOutput(result); diff --git a/packages/cli/src/lib/api/explore.ts b/packages/cli/src/lib/api/explore.ts index 54bbcf7ce..dcded3375 100644 --- a/packages/cli/src/lib/api/explore.ts +++ b/packages/cli/src/lib/api/explore.ts @@ -43,12 +43,6 @@ export type ExploreQueryOptions = { start?: string; /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ end?: string; - /** - * Numeric project IDs to scope the query to. Passed as repeated `project` - * params. Avoids the "project not actively selected" error when querying a - * specific project's events. - */ - project?: string[]; }; /** @@ -86,7 +80,6 @@ async function fetchEventsPage( start: options.start, end: options.end, cursor: options.cursor, - project: options.project, }, schema: EventsTableResponseSchema, }, diff --git a/packages/cli/src/lib/api/issue-scoped-counts.ts b/packages/cli/src/lib/api/issue-scoped-counts.ts deleted file mode 100644 index 4a153c000..000000000 --- a/packages/cli/src/lib/api/issue-scoped-counts.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Scoped issue counts via the Sentry events endpoint. - * - * The issues-list endpoint's per-group seen-stats (`count`, `userCount`) honor - * the `statsPeriod` but are NOT scoped to arbitrary query filters like - * `release:` — they report period-wide totals across every value of the key. - * The events endpoint, in contrast, returns counts that actually match the - * filter. We re-query it per issue with `issue:` AND the user's query - * to replace the over-reported numbers. See getsentry/cli#1518. - */ - -import pLimit from "p-limit"; -import { logger } from "../logger.js"; -import { queryEvents } from "./explore.js"; -import { ORG_FANOUT_CONCURRENCY } from "./infrastructure.js"; - -const log = logger.withTag("issue-scoped-counts"); - -/** Filter-scoped event and affected-user counts for a single issue. */ -export type ScopedCount = { - count: number; - userCount: number; -}; - -/** Time range + project scope shared by scoped-count queries. */ -type ScopedCountTimeOptions = { - statsPeriod?: string; - start?: string; - end?: string; -}; - -/** - * Fetch filter-scoped event & user counts for a single issue via the events - * endpoint. Returns `undefined` on any failure so the caller keeps the - * (unscoped) list-endpoint numbers rather than breaking the listing. - * - * @param orgSlug - Organization slug - * @param shortId - Issue short ID (scopes to the one group via `issue:`) - * @param options - The user's query plus time range and optional project ID - */ -export async function fetchScopedCount( - orgSlug: string, - shortId: string, - options: ScopedCountTimeOptions & { - /** The user's --query (already sanitized). */ - query: string; - /** Numeric project ID to scope the events query to. */ - projectId?: number; - }, -): Promise { - const query = `issue:${shortId} ${options.query}`.trim(); - try { - const response = await queryEvents(orgSlug, { - fields: ["count()", "count_unique(user)"], - dataset: "errors", - query, - statsPeriod: options.statsPeriod, - start: options.start, - end: options.end, - project: - options.projectId === undefined - ? undefined - : [String(options.projectId)], - limit: 1, - }); - const row = response.data.data[0]; - if (!row) { - return; - } - return { - count: Number(row["count()"] ?? 0), - userCount: Number(row["count_unique(user)"] ?? 0), - }; - } catch (error) { - log.debug(`Scoped count refetch failed for ${shortId}: ${String(error)}`); - return; - } -} - -/** - * Fetch scoped counts for many issues in parallel (bounded concurrency), - * returning a Map keyed by issue short ID. Issues whose refetch fails are - * absent from the map so the caller keeps the unscoped fallback. - */ -export async function fetchScopedCounts( - orgSlug: string, - issues: { shortId: string; projectId?: number }[], - options: ScopedCountTimeOptions & { query: string }, -): Promise> { - const limit = pLimit(ORG_FANOUT_CONCURRENCY); - const results = await Promise.all( - issues.map((issue) => - limit(async () => { - const scoped = await fetchScopedCount(orgSlug, issue.shortId, { - query: options.query, - statsPeriod: options.statsPeriod, - start: options.start, - end: options.end, - projectId: issue.projectId, - }); - return { shortId: issue.shortId, scoped }; - }), - ), - ); - - const map = new Map(); - for (const { shortId, scoped } of results) { - if (scoped) { - map.set(shortId, scoped); - } - } - return map; -} diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index e47f25650..00b95afc5 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -59,10 +59,11 @@ export type IssueCollapseField = NonNullable< /** * Build the `collapse` parameter for issue list API calls. * - * Always collapses `filtered` and `unhandled` — the CLI never consumes - * these in issue list views. Conditionally collapses `stats` when the caller - * confirms seen-stats fields aren't needed, and `lifetime` when the caller - * confirms lifetime-dependent fields aren't needed. + * Always collapses `unhandled` — the CLI never consumes it in issue list + * views. Collapses `filtered` unless the caller needs query-scoped counts. + * Conditionally collapses `stats` when the caller confirms seen-stats fields + * aren't needed, and `lifetime` when the caller confirms lifetime-dependent + * fields aren't needed. * * **Important:** `collapse=stats` skips Snuba seen-stats queries entirely, * stripping top-level `count`, `userCount`, `firstSeen`, `lastSeen` and the @@ -80,13 +81,22 @@ export type IssueCollapseField = NonNullable< * Defaults to `false` because most output paths need `count`/`userCount`/ * `firstSeen`/`lastSeen`. Only set to `true` when `--json --fields` omits * all lifetime-dependent fields. + * @param options.shouldCollapseFiltered - Whether the `filtered` block can be + * skipped. Defaults to `true`. Set to `false` to receive counts scoped to + * the query's event-level filters (e.g. `release:`), which the top-level + * `count`/`userCount` ignore. The server only returns `filtered` when + * `stats` is not collapsed. * @returns Array of fields to collapse */ export function buildIssueListCollapse(options: { shouldCollapseStats: boolean; shouldCollapseLifetime?: boolean; + shouldCollapseFiltered?: boolean; }): IssueCollapseField[] { - const collapse: IssueCollapseField[] = ["filtered", "unhandled"]; + const collapse: IssueCollapseField[] = + (options.shouldCollapseFiltered ?? true) + ? ["filtered", "unhandled"] + : ["unhandled"]; if (options.shouldCollapseLifetime) { collapse.push("lifetime"); } diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 8ee7f881b..8f0127048 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -53,8 +53,8 @@ const INVALID_INLIST_KEYS = new Set(["is", "has"]); * A query filtering on one of these returns the groups matching the filter, * but the displayed counts remain period-wide totals across every value of the * key — e.g. `release:"X"` restricts the result set yet the counts still cover - * all releases. Callers must be told so they don't attribute the numbers to the - * filter. See getsentry/cli#1518. + * all releases. `issue list` requests the endpoint's query-scoped `filtered` + * counts instead when one of these keys is present. See getsentry/toolkit#1583. */ const COUNT_UNSCOPED_FILTER_KEYS = new Set(["release"]); @@ -730,9 +730,9 @@ function collectUnscopedCountKeys( * but are NOT reflected in the issues-list endpoint's seen-stats * (`count`, `userCount`, `firstSeen`, `lastSeen`). See getsentry/cli#1518. * - * Used to warn callers that a release-scoped `issue list` reports period-wide - * counts, not counts matching the filter. Returns an empty array when the - * query is absent, unparseable, or contains no such keys. + * Used by `issue list` to decide when to request query-scoped counts. + * Returns an empty array when the query is absent, unparseable, or contains + * no such keys. * * @param query - Raw `--query` value, or `undefined` * @returns Lower-cased matching keys (e.g. `["release"]`), deduplicated diff --git a/packages/cli/src/types/sentry.ts b/packages/cli/src/types/sentry.ts index c9b9a137d..0ee3f7b58 100644 --- a/packages/cli/src/types/sentry.ts +++ b/packages/cli/src/types/sentry.ts @@ -17,6 +17,7 @@ import type { IssueEventDetailsResponse, DeployResponse as SdkDeployResponse, GetOrganizationIssueResponse as SdkIssueDetail, + ListOrganizationIssuesResponse as SdkIssueList, ListOrganizations as SdkOrganizationList, ProjectKey as SdkProjectKey, OrganizationProjectResponseDict as SdkProjectList, @@ -191,6 +192,12 @@ export type SentryIssue = Omit, "metadata"> & * `null` when Seer has not analyzed this issue; absent when the org has Seer disabled. */ seerFixabilityScore?: number | null; + /** + * Seen-stats scoped to the query's event-level filters (e.g. `release:`). + * Returned by the issues-list endpoint only when `filtered` isn't + * collapsed; `null` when the query has no such filters. + */ + filtered?: SdkIssueList[number]["filtered"]; }; /** diff --git a/packages/cli/test/commands/issue/list.test.ts b/packages/cli/test/commands/issue/list.test.ts index 10d1ee64b..dc75dcd61 100644 --- a/packages/cli/test/commands/issue/list.test.ts +++ b/packages/cli/test/commands/issue/list.test.ts @@ -782,59 +782,38 @@ describe("issue list: server sort order preservation", () => { }); }); -describe("issue list: scoped counts via events endpoint (#1518)", () => { +describe("issue list: query-scoped counts (#1583)", () => { /** - * Mock a single issue from the list endpoint and (optionally) a scoped-count - * response from the events endpoint. Tracks the events-endpoint URLs seen so - * tests can assert what was queried. + * Serve `issues` from the list endpoint and record each list request URL so + * tests can assert which response blocks were collapsed. */ - function mockScopedCounts(options?: { - events?: { count: number; userCount: number } | "error"; - issues?: Record[]; - }) { - const eventsUrls: string[] = []; + function mockIssueList(issues: Record[]) { + const issueUrls: URL[] = []; globalThis.fetch = mockFetch(async (input, init) => { const req = new Request(input, init); const projectResp = mockDefaultProject(req.url); if (projectResp) return projectResp; - if (req.url.includes("/events/")) { - eventsUrls.push(req.url); - if (options?.events === "error") { - return new Response(JSON.stringify({ detail: "boom" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } - const ev = options?.events ?? { count: 0, userCount: 0 }; - return Response.json({ - data: [ - { - "count()": ev.count, - "count_unique(user)": ev.userCount, - }, - ], - meta: {}, - }); - } if (req.url.includes("/issues/")) { - const issues = options?.issues ?? [mockIssue()]; - return new Response(JSON.stringify(issues), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + issueUrls.push(new URL(req.url)); + return Response.json(issues); } - return new Response(JSON.stringify([]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return Response.json([]); }); - return { eventsUrls }; + return { issueUrls }; } - test("replaces counts with scoped values from the events endpoint", async () => { - const { eventsUrls } = mockScopedCounts({ - events: { count: 238, userCount: 189 }, - }); + const releaseFiltered = { + count: "238", + userCount: 189, + firstSeen: "2025-01-01T00:00:00Z", + lastSeen: "2025-01-01T00:00:00Z", + stats: {}, + }; + + test("requests filtered stats and uses them for a release: query", async () => { + const { issueUrls } = mockIssueList([ + mockIssue({ count: "639", userCount: 442, filtered: releaseFiltered }), + ]); const { context, stdout } = createContext(); await func.call(context, { @@ -845,21 +824,19 @@ describe("issue list: scoped counts via events endpoint (#1518)", () => { query: 'release:"1.0.0"', }); - // The events endpoint is queried with issue: AND the release filter. - expect(eventsUrls.length).toBe(1); - const decoded = decodeURIComponent(eventsUrls[0] ?? ""); - expect(decoded).toContain("issue:TEST-PROJECT-1"); - expect(decoded).toContain('release:"1.0.0"'); + const collapse = issueUrls[0]?.searchParams.getAll("collapse"); + expect(collapse).toContain("unhandled"); + expect(collapse).not.toContain("filtered"); + expect(collapse).not.toContain("stats"); const output = JSON.parse(stdout.output); expect(output.data[0].count).toBe("238"); expect(output.data[0].userCount).toBe(189); + expect(output.data[0]).not.toHaveProperty("filtered"); }); - test("does not call the events endpoint without an unscoped filter", async () => { - const { eventsUrls } = mockScopedCounts({ - events: { count: 238, userCount: 189 }, - }); + test("keeps filtered collapsed without a release: filter", async () => { + const { issueUrls } = mockIssueList([mockIssue()]); const { context, stdout } = createContext(); await func.call(context, { @@ -870,15 +847,14 @@ describe("issue list: scoped counts via events endpoint (#1518)", () => { query: "is:unresolved", }); - expect(eventsUrls.length).toBe(0); + expect(issueUrls[0]?.searchParams.getAll("collapse")).toContain("filtered"); const output = JSON.parse(stdout.output); - // Original list-endpoint count is preserved. expect(output.data[0].count).toBe("10"); expect(output.data[0].userCount).toBe(5); }); - test("falls back to unscoped counts when the events refetch fails", async () => { - mockScopedCounts({ events: "error" }); + test("keeps top-level counts when the server returns filtered: null", async () => { + mockIssueList([mockIssue({ filtered: null })]); const { context, stdout } = createContext(); await func.call(context, { @@ -890,67 +866,29 @@ describe("issue list: scoped counts via events endpoint (#1518)", () => { }); const output = JSON.parse(stdout.output); - // Listing still renders with the original list-endpoint counts. expect(output.data[0].count).toBe("10"); expect(output.data[0].userCount).toBe(5); + expect(output.data[0]).not.toHaveProperty("filtered"); }); - test.each([ - { label: "with counts shown", fields: undefined }, - { label: "when --fields omits counts", fields: ["id", "title"] }, - ])("--sort freq re-ranks by scoped counts $label", async ({ fields }) => { - // List endpoint orders by unscoped counts: A (100) before B (50). - const issues = [ - mockIssue({ id: "A", shortId: "AAA-1", count: "100", userCount: 100 }), - mockIssue({ id: "B", shortId: "BBB-2", count: "50", userCount: 50 }), - ]; - // Scoped counts invert the ranking: B (90) should outrank A (5). - globalThis.fetch = mockFetch(async (input, init) => { - const req = new Request(input, init); - const projectResp = mockDefaultProject(req.url); - if (projectResp) return projectResp; - if (req.url.includes("/events/")) { - const decoded = decodeURIComponent(req.url); - const scoped = decoded.includes("issue:AAA-1") - ? { count: 5, userCount: 5 } - : { count: 90, userCount: 90 }; - return Response.json({ - data: [ - { - "count()": scoped.count, - "count_unique(user)": scoped.userCount, - }, - ], - meta: {}, - }); - } - if (req.url.includes("/issues/")) { - return new Response(JSON.stringify(issues), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - return new Response(JSON.stringify([]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }); - const { context, stdout } = createContext(); + test("keeps stats for --sort freq even when --fields omits counts", async () => { + const { issueUrls } = mockIssueList([ + mockIssue({ filtered: releaseFiltered }), + ]); + const { context } = createContext(); await func.call(context, { limit: 10, sort: "freq", period: parsePeriod("90d"), json: true, - fields, + fields: ["id", "title"], query: 'release:"1.0.0"', }); - const output = JSON.parse(stdout.output); - expect(output.data.map((issue: { id: string }) => issue.id)).toEqual([ - "B", - "A", - ]); + const collapse = issueUrls[0]?.searchParams.getAll("collapse"); + expect(collapse).not.toContain("filtered"); + expect(collapse).not.toContain("stats"); }); }); @@ -1845,6 +1783,61 @@ describe("issue list: multi-target cursor-safe budget", () => { undefined, ); }); + + test("merges and trims multi-project --sort freq by release-scoped counts (#1583)", async () => { + findProjectsBySlugMock.mockResolvedValue({ + projects: [ + { id: "1", slug: "myproj", name: "My Project", orgSlug: "org-one" }, + { id: "2", slug: "myproj", name: "My Project", orgSlug: "org-two" }, + ], + orgs: [], + }); + const scoped = (id: string, count: number, filteredCount: number) => + mockIssue({ + id, + shortId: id.toUpperCase(), + project: { slug: "myproj" }, + count: String(count), + userCount: count, + filtered: { + count: String(filteredCount), + userCount: filteredCount, + firstSeen: null, + lastSeen: null, + stats: {}, + }, + }); + // Each server page is already ordered by the release-scoped count. + // Period-wide counts would rank one-a and one-b above two-a instead. + const pages: Record[]> = { + "org-one": [scoped("one-b", 80, 70), scoped("one-a", 100, 5)], + "org-two": [scoped("two-a", 50, 90)], + }; + listIssuesAllPagesMock.mockImplementation(async (org) => ({ + issues: pages[org] ?? [], + })); + + const { context, stdout } = createContext(); + await func.call( + context, + { + limit: 2, + sort: "freq", + period: parsePeriod("90d"), + json: true, + query: 'release:"1.0.0"', + }, + "myproj", + ); + + const output = JSON.parse(stdout.output); + expect( + output.data.map((i: { id: string; count: string }) => [i.id, i.count]), + ).toEqual([ + ["two-a", "90"], + ["one-b", "70"], + ]); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/cli/test/lib/issue-collapse.property.test.ts b/packages/cli/test/lib/issue-collapse.property.test.ts index 404e9413c..df13a4539 100644 --- a/packages/cli/test/lib/issue-collapse.property.test.ts +++ b/packages/cli/test/lib/issue-collapse.property.test.ts @@ -15,7 +15,7 @@ import { import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; describe("property: buildIssueListCollapse", () => { - test("always collapses filtered and unhandled regardless of optional flags", () => { + test("collapses filtered and unhandled by default regardless of other flags", () => { fcAssert( property( tuple(boolean(), boolean()), @@ -64,6 +64,24 @@ describe("property: buildIssueListCollapse", () => { ); }); + test("filtered presence is exactly controlled by shouldCollapseFiltered", () => { + fcAssert( + property( + tuple(boolean(), boolean(), boolean()), + ([collapseStats, collapseLifetime, collapseFiltered]) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + shouldCollapseLifetime: collapseLifetime, + shouldCollapseFiltered: collapseFiltered, + }); + expect(result.includes("filtered")).toBe(collapseFiltered); + expect(result).toContain("unhandled"); + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + test("defaults to not collapsing lifetime when option omitted", () => { fcAssert( property(boolean(), (collapseStats) => {