From 05adec1da0a3f8ce13c155547450fdc6d1cb6d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 21 Sep 2026 17:01:04 +0200 Subject: [PATCH 1/2] feat(issue): view multiple issues in one invocation Agents currently loop `issue view` per ID. Match `event view` with space-separated/newline IDs and a JSON array only for multi-id calls, without changing the shared single-id positional used by explain/plan/resolve. Co-authored-by: Cursor --- .../src/content/docs/agent-guidance.md | 3 +- apps/cli-docs/src/fragments/commands/issue.md | 9 +- packages/cli/CONTRIBUTING.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 5 +- .../skills/sentry-cli/references/issue.md | 10 +- packages/cli/src/commands/issue/index.ts | 3 +- packages/cli/src/commands/issue/view.ts | 326 +++++++++++++----- .../cli/test/commands/issue/view.func.test.ts | 292 ++++++++++++++-- packages/cli/test/commands/issue/view.test.ts | 173 ++++++++++ .../script/generate-skill-markdown.test.ts | 1 + 10 files changed, 715 insertions(+), 109 deletions(-) create mode 100644 packages/cli/test/commands/issue/view.test.ts diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 05203ce21d..c6acd86402 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -11,7 +11,7 @@ Best practices and operational guidance for AI coding agents using the Sentry CL - **Prefer CLI commands over raw API calls** — the CLI has dedicated commands for most tasks. Reach for `sentry issue view`, `sentry issue list`, `sentry trace view`, etc. before constructing API calls manually or fetching external documentation. - **Use `sentry docs` for setup questions** — if you need to know how to configure a Sentry SDK or feature, run `sentry docs "your question"` to query the documentation directly. This is faster and more accurate than fetching docs externally. - **Use `sentry schema` to explore the API** — if you need to discover API endpoints, run `sentry schema` to browse interactively or `sentry schema ` to search. This is faster than fetching OpenAPI specs externally. -- **Use `sentry issue view ` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. +- **Use `sentry issue view` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. Multiple IDs can be passed in one invocation: `sentry issue view A B C --json` returns an array of the same objects. - **Use `--json` for machine-readable output** — pipe through `jq` for filtering. Human-readable output includes formatting that is hard to parse. - **The CLI auto-detects org/project — don't discover it yourself** — most commands work without explicit targets by checking `.sentryclirc` config files, scanning for DSNs in `.env` files and source code, and matching directory names. Do **not** run `sentry org list` and then `sentry project list` to figure out which project this checkout belongs to — that manual fan-out just replicates the detection the CLI already runs on every command. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. @@ -28,6 +28,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia - Use `--json` when piping output between commands or processing programmatically - Use `--limit` to cap the number of results (default is usually 10–100) - Prefer `sentry issue view PROJECT-123` over listing and filtering manually +- Pass multiple issue IDs in one call (`sentry issue view A B C --json`) instead of looping `issue view` per ID - Use `sentry api` for endpoints not covered by dedicated commands ## Safety Rules diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 41fd673b0a..86d76f571d 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -103,6 +103,9 @@ sentry issue events FRONT-ABC -c next ```bash sentry issue view FRONT-ABC + +# Multiple issues in one invocation (space-separated, not commas) +sentry issue view FRONT-ABC BACK-2 ``` ``` @@ -135,12 +138,16 @@ sentry issue view my-project#FRONT-ABC `--json` returns the issue fields at the top level plus the latest event under `event`, the resolved `org` slug, related `replayIds`, and `trace` context. -Prefer this over the human output when parsing programmatically. +Prefer this over the human output when parsing programmatically. One issue ID +still returns a single object; multiple IDs return an array of those objects. ```bash # Full JSON (issue fields + latest event + trace/replay context) sentry issue view FRONT-ABC --json +# Multiple issues: JSON is an array of the same objects +sentry issue view FRONT-ABC BACK-2 --json + # Select specific top-level fields to keep output small sentry issue view FRONT-ABC --json --fields shortId,title,culprit,count,userCount,permalink diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index 154407f46c..b0c787611c 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -31,7 +31,7 @@ View commands use **optional positional arguments** for the primary identifier, ```bash sentry org view [org-slug] [--json] [-w] # works with DSN if no arg sentry project view [/] [--json] [-w] # works with DSN if no arg -sentry issue view [--json] [-w] # issue ID required +sentry issue view [...] [--json] [-w] # one or more issue IDs sentry event view [/] [--json] [-w] # event ID required ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 66c6161ba4..2b8ee1e1b4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -23,7 +23,7 @@ Best practices and operational guidance for AI coding agents using the Sentry CL - **Prefer CLI commands over raw API calls** — the CLI has dedicated commands for most tasks. Reach for `sentry issue view`, `sentry issue list`, `sentry trace view`, etc. before constructing API calls manually or fetching external documentation. - **Use `sentry docs` for setup questions** — if you need to know how to configure a Sentry SDK or feature, run `sentry docs "your question"` to query the documentation directly. This is faster and more accurate than fetching docs externally. - **Use `sentry schema` to explore the API** — if you need to discover API endpoints, run `sentry schema` to browse interactively or `sentry schema ` to search. This is faster than fetching OpenAPI specs externally. -- **Use `sentry issue view ` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. +- **Use `sentry issue view` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. Multiple IDs can be passed in one invocation: `sentry issue view A B C --json` returns an array of the same objects. - **Use `--json` for machine-readable output** — pipe through `jq` for filtering. Human-readable output includes formatting that is hard to parse. - **The CLI auto-detects org/project — don't discover it yourself** — most commands work without explicit targets by checking `.sentryclirc` config files, scanning for DSNs in `.env` files and source code, and matching directory names. Do **not** run `sentry org list` and then `sentry project list` to figure out which project this checkout belongs to — that manual fan-out just replicates the detection the CLI already runs on every command. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. @@ -40,6 +40,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia - Use `--json` when piping output between commands or processing programmatically - Use `--limit` to cap the number of results (default is usually 10–100) - Prefer `sentry issue view PROJECT-123` over listing and filtering manually +- Pass multiple issue IDs in one call (`sentry issue view A B C --json`) instead of looping `issue view` per ID - Use `sentry api` for endpoints not covered by dedicated commands ### Safety Rules @@ -410,7 +411,7 @@ Manage Sentry issues - `sentry issue events ` — List events for a specific issue - `sentry issue explain ` — Analyze an issue's root cause using Seer AI - `sentry issue plan ` — Generate a solution plan using Seer AI -- `sentry issue view ` — View details of a specific issue +- `sentry issue view ` — View details of one or more issues - `sentry issue resolve ` — Mark an issue as resolved - `sentry issue unresolve ` — Reopen a resolved issue - `sentry issue archive ` — Archive (ignore) an issue diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index a682602faa..3e3fcbf939 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -179,9 +179,9 @@ Generate a solution plan using Seer AI - `--force - Force new plan even if one exists` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` -### `sentry issue view ` +### `sentry issue view ` -View details of a specific issue +View details of one or more issues **Flags:** - `-w, --web - Open in browser` @@ -221,6 +221,9 @@ View details of a specific issue ```bash sentry issue view FRONT-ABC +# Multiple issues in one invocation (space-separated, not commas) +sentry issue view FRONT-ABC BACK-2 + # Open in browser sentry issue view FRONT-ABC -w @@ -231,6 +234,9 @@ sentry issue view my-project#FRONT-ABC # Full JSON (issue fields + latest event + trace/replay context) sentry issue view FRONT-ABC --json +# Multiple issues: JSON is an array of the same objects +sentry issue view FRONT-ABC BACK-2 --json + # Select specific top-level fields to keep output small sentry issue view FRONT-ABC --json --fields shortId,title,culprit,count,userCount,permalink diff --git a/packages/cli/src/commands/issue/index.ts b/packages/cli/src/commands/issue/index.ts index 09b72e94bb..33fb2b3e79 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -31,7 +31,7 @@ export const issueRoute = buildRouteMap({ "Commands:\n" + " list List issues in a project\n" + " events List events for a specific issue\n" + - " view View details of a specific issue\n" + + " view View details of one or more issues\n" + " explain Analyze an issue using Seer AI\n" + " plan Generate a solution plan using Seer AI\n" + " resolve Mark an issue as resolved (optionally in a release)\n" + @@ -43,6 +43,7 @@ export const issueRoute = buildRouteMap({ " @most_frequent Issue with the highest event frequency\n\n" + "Examples:\n" + " sentry issue view @latest\n" + + " sentry issue view FRONT-ABC BACK-2\n" + " sentry issue events CLI-G\n" + " sentry issue resolve CLI-12Z --in 0.26.1\n" + " sentry issue archive CLI-AB --until auto\n" + diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index ba5e6fcf4e..c3ba72a5b8 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -1,14 +1,20 @@ /** * sentry issue view * - * View detailed information about a Sentry issue. + * View detailed information about one or more Sentry issues. */ +import pLimit from "p-limit"; import type { SentryContext } from "../../context.js"; -import { getLatestEvent, listReplayIdsForIssue } from "../../lib/api-client.js"; -import { spansFlag } from "../../lib/arg-parsing.js"; +import { + getLatestEvent, + listReplayIdsForIssue, + ORG_FANOUT_CONCURRENCY, +} from "../../lib/api-client.js"; +import { spansFlag, splitNewlineArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; import { formatEventDetails, formatIssueDetails, @@ -31,10 +37,13 @@ import { import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, SentryIssue } from "../../types/index.js"; import { IssueViewOutputSchema } from "../../types/index.js"; -import { issueIdPositional, resolveIssue } from "./utils.js"; +import { resolveIssue } from "./utils.js"; const log = logger.withTag("issue.view"); +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry issue view [...]"; + type ViewFlags = { readonly json: boolean; readonly web: boolean; @@ -78,8 +87,8 @@ async function tryListReplayIdsForIssue( } } -/** Return type for issue view — includes all data both renderers need */ -type IssueViewData = { +/** Per-issue payload both renderers need */ +type SingleIssueViewData = { org: string | null; issue: SentryIssue; event: SentryEvent | null; @@ -89,6 +98,17 @@ type IssueViewData = { spanTreeLines?: string[]; }; +/** + * Output type for issue view — supports both single and multi-issue. + * Multi-issue output occurs when agents pass several IDs or paste + * newline-separated IDs (same contract as `event view`). + */ +type IssueViewData = { + issues: SingleIssueViewData[]; + /** Number of issues originally requested (before partial failures) */ + requestedCount: number; +}; + const MAX_REPLAY_IDS_SHOWN = 3; function formatReplaySection(org: string | null, replayIds: string[]): string { @@ -120,11 +140,9 @@ function formatReplaySection(org: string | null, replayIds: string[]): string { } /** - * Format issue view data for human-readable terminal output. - * - * Renders issue details, optional latest event, and optional span tree. + * Format one issue's view data for human-readable terminal output. */ -function formatIssueView(data: IssueViewData): string { +function formatSingleIssueView(data: SingleIssueViewData): string { const parts: string[] = []; const eventReplayId = data.event ? getReplayIdFromEvent(data.event) @@ -153,41 +171,208 @@ function formatIssueView(data: IssueViewData): string { return parts.join("\n"); } +/** + * Format issue view data for human-readable terminal output. + * + * Renders issue details, optional latest event, and optional span tree. + * Multiple issues are separated by horizontal rules. + */ +export function formatIssueView(data: IssueViewData): string { + const parts: string[] = []; + + for (const entry of data.issues) { + if (parts.length > 0) { + parts.push("\n---\n"); + } + parts.push(formatSingleIssueView(entry)); + } + + return parts.join("\n"); +} + +function flattenIssueView( + entry: SingleIssueViewData, + fields?: string[] +): Record { + const result: Record = { + ...entry.issue, + event: entry.event, + org: entry.org, + replayIds: entry.replayIds, + trace: entry.trace, + }; + if (fields && fields.length > 0) { + return filterFields(result, fields) as Record; + } + return result; +} + /** * Transform issue view data for JSON output. * - * Flattens the issue as the primary object so that `--fields shortId,title` - * works directly on issue properties. The `event`, `trace`, `org`, and - * `replayIds` enrichment data are attached as sibling keys, accessible via - * `--fields event.id`, `--fields trace.traceId`, or `--fields replayIds`. + * For single-issue output, flattens the issue as the primary object so that + * `--fields shortId,title` works directly on issue properties. The `event`, + * `trace`, `org`, and `replayIds` enrichment data are attached as sibling + * keys, accessible via `--fields event.id`, `--fields trace.traceId`, or + * `--fields replayIds`. + * + * For multi-issue output, returns an array of flattened issue objects. + * This preserves backward compatibility: single-issue callers still get + * a flat object, while multi-issue callers get an array. * - * Without this transform, `--fields shortId` would return `{}` because - * the raw yield shape is `{ issue, event, trace }` and `shortId` lives - * inside `issue`. + * Use requestedCount (not issues.length) to decide the shape so that + * partial failures don't non-deterministically switch from array to object. */ -function jsonTransformIssueView( +export function jsonTransformIssueView( data: IssueViewData, fields?: string[] ): unknown { - const { issue, event, org, replayIds, trace } = data; - const result: Record = { - ...issue, - event, - org, + if (data.requestedCount <= 1) { + const [first] = data.issues; + if (first) { + return flattenIssueView(first, fields); + } + } + return data.issues.map((entry) => flattenIssueView(entry, fields)); +} + +/** + * Expand positional args by splitting each on newlines. + * + * When an agent pastes `"IOS-1\nIOS-2\nIOS-3"` as a single arg, this + * produces `["IOS-1", "IOS-2", "IOS-3"]`. Commas are left intact — + * positional values are space-separated, never comma-split. + */ +export function expandNewlineArgs(args: string[]): string[] { + return args.flatMap(splitNewlineArg); +} + +/** + * Expand newlines and drop duplicate tokens, preserving first-seen order. + */ +export function collectIssueArgs(args: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const arg of expandNewlineArgs(args)) { + if (!seen.has(arg)) { + seen.add(arg); + result.push(arg); + } + } + return result; +} + +/** + * Resolve one issue and attach latest event, replays, and optional span tree. + */ +async function buildSingleIssueViewData( + issueArg: string, + cwd: string, + spans: number +): Promise { + const { org: orgSlug, issue } = await resolveIssue({ + issueArg, + cwd, + command: "view", + }); + + const [event, relatedReplayIds] = orgSlug + ? await Promise.all([ + tryGetLatestEvent(orgSlug, issue.id), + tryListReplayIdsForIssue(orgSlug, issue.id), + ]) + : [undefined, []]; + const replayIds = collectReplayIds([ + event ? getReplayIdFromEvent(event) : undefined, + ...relatedReplayIds, + ]); + + let spanTreeResult: Awaited> | undefined; + if (orgSlug && event && spans > 0) { + spanTreeResult = await getSpanTreeLines(orgSlug, event, spans); + } + + let spanTreeLines: string[] | undefined; + if (spanTreeResult) { + spanTreeLines = spanTreeResult.lines; + } else if (!orgSlug) { + const msg = "\nOrganization context required to fetch span tree."; + spanTreeLines = [isPlainOutput() ? msg : muted(msg)]; + } else if (!event) { + const msg = "\nCould not fetch event to display span tree."; + spanTreeLines = [isPlainOutput() ? msg : muted(msg)]; + } + + const trace = spanTreeResult?.success + ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } + : null; + + return { + org: orgSlug ?? null, + issue, + event: event ?? null, replayIds, trace, + spanTreeLines, }; - if (fields && fields.length > 0) { - return filterFields(result, fields); +} + +/** Options for fetching multiple issues in parallel */ +type FetchMultipleIssueViewsOptions = { + /** Issue identifiers as provided on the command line */ + issueArgs: string[]; + /** Working directory for DSN / project detection */ + cwd: string; + /** Span tree depth (`0` skips the fetch) */ + spans: number; +}; + +/** + * Fetch multiple issues with bounded concurrency, collecting successes + * and warning on failures. + * + * Uses {@link ORG_FANOUT_CONCURRENCY} (5) to avoid overwhelming the API + * when agents paste dozens of IDs. Mirrors `event view`'s fetchMultipleEvents. + * + * When all fetches fail, re-throws the error from the primary (first) issue. + */ +export async function fetchMultipleIssueViews( + options: FetchMultipleIssueViewsOptions +): Promise { + const { issueArgs, cwd, spans } = options; + const limit = pLimit(ORG_FANOUT_CONCURRENCY); + + const results = await Promise.allSettled( + issueArgs.map((issueArg) => + limit(() => buildSingleIssueViewData(issueArg, cwd, spans)) + ) + ); + + const views: SingleIssueViewData[] = []; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result?.status === "fulfilled") { + views.push(result.value); + } else if (result?.status === "rejected") { + log.warn(`Failed to fetch issue ${issueArgs[i]}: ${result.reason}`); + } } - return result; + + if (views.length === 0) { + const firstResult = results[0]; + if (firstResult?.status === "rejected") { + throw firstResult.reason; + } + } + + return views; } export const viewCommand = buildCommand({ docs: { - brief: "View details of a specific issue", + brief: "View details of one or more issues", fullDescription: - "View detailed information about a Sentry issue by its ID or short ID. " + + "View detailed information about Sentry issues by ID or short ID. " + "The latest event is automatically included for full context.\n\n" + "Issue formats:\n" + " @latest - Most recent unresolved issue\n" + @@ -199,6 +384,8 @@ export const viewCommand = buildCommand({ " suffix - Suffix only: G (requires DSN context)\n" + " numeric - Numeric ID: 123456789\n" + " org/project#ID - GitHub-style: my-org/my-project#PROJ-123\n\n" + + "Multiple issue IDs can be passed as separate arguments or newline-separated\n" + + "within a single argument (handy when piping from other commands).\n\n" + "In multi-project mode (after 'issue list'), use alias-suffix format (e.g., 'f-g' " + "where 'f' is the project alias shown in the list).", }, @@ -208,7 +395,14 @@ export const viewCommand = buildCommand({ schema: IssueViewOutputSchema, }, parameters: { - positional: issueIdPositional, + positional: { + kind: "array", + parameter: { + placeholder: "issue", + brief: " [...] - One or more issue IDs", + parse: String, + }, + }, flags: { web: { kind: "boolean", @@ -220,69 +414,43 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async *func(this: SentryContext, flags: ViewFlags, issueArg: string) { + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd } = this; - // Resolve issue using shared resolution logic - const { org: orgSlug, issue } = await resolveIssue({ - issueArg, - cwd, - command: "view", - }); + const issueArgs = collectIssueArgs(args); + const [primaryArg] = issueArgs; + if (primaryArg === undefined) { + throw new ContextError("Issue ID", USAGE_HINT, []); + } if (flags.web) { + if (issueArgs.length > 1) { + log.warn( + "--web only opens the first issue; extra issue IDs are ignored." + ); + } + const { issue } = await resolveIssue({ + issueArg: primaryArg, + cwd, + command: "view", + }); await openInBrowser(issue.permalink, "issue"); return; } - // Fetch the latest event for full context (requires org slug) - const [event, relatedReplayIds] = orgSlug - ? await Promise.all([ - tryGetLatestEvent(orgSlug, issue.id), - tryListReplayIdsForIssue(orgSlug, issue.id), - ]) - : [undefined, []]; - const replayIds = collectReplayIds([ - event ? getReplayIdFromEvent(event) : undefined, - ...relatedReplayIds, - ]); - - // Fetch span tree data (for both JSON and human output) - // Skip when spans=0 (disabled via --spans no or --spans 0) - let spanTreeResult: - | Awaited> - | undefined; - if (orgSlug && event && flags.spans > 0) { - spanTreeResult = await getSpanTreeLines(orgSlug, event, flags.spans); - } - - // Prepare span tree lines for human output - let spanTreeLines: string[] | undefined; - if (spanTreeResult) { - spanTreeLines = spanTreeResult.lines; - } else if (!orgSlug) { - const msg = "\nOrganization context required to fetch span tree."; - spanTreeLines = [isPlainOutput() ? msg : muted(msg)]; - } else if (!event) { - const msg = "\nCould not fetch event to display span tree."; - spanTreeLines = [isPlainOutput() ? msg : muted(msg)]; - } - - const trace = spanTreeResult?.success - ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } - : null; + const views = await fetchMultipleIssueViews({ + issueArgs, + cwd, + spans: flags.spans, + }); yield new CommandOutput({ - org: orgSlug ?? null, - issue, - event: event ?? null, - replayIds, - trace, - spanTreeLines, + issues: views, + requestedCount: issueArgs.length, }); return { - hint: `Tip: Use 'sentry issue explain ${issueArg}' for AI root cause analysis`, + hint: `Tip: Use 'sentry issue explain ${primaryArg}' for AI root cause analysis`, }; }, }); diff --git a/packages/cli/test/commands/issue/view.func.test.ts b/packages/cli/test/commands/issue/view.func.test.ts index bbb81ea63a..3f73a9f03a 100644 --- a/packages/cli/test/commands/issue/view.func.test.ts +++ b/packages/cli/test/commands/issue/view.func.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the issue view command's replay integration. + * Tests for the issue view command's replay integration and multi-id fetch. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -19,7 +19,10 @@ vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => { // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as issueUtils from "../../../src/commands/issue/utils.js"; -import { viewCommand } from "../../../src/commands/issue/view.js"; +import { + fetchMultipleIssueViews, + viewCommand, +} from "../../../src/commands/issue/view.js"; vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { const actual = @@ -34,6 +37,21 @@ vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/browser.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; +import { ContextError } from "../../../src/lib/errors.js"; import type { SentryEvent, SentryIssue } from "../../../src/types/index.js"; const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; @@ -59,22 +77,29 @@ function sampleEvent(overrides: Partial = {}): SentryEvent { }; } +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +const VIEW_FLAGS = { + json: true, + web: false, + spans: 0, + fresh: false, +} as const; + describe("issue view replay integration", () => { - let resolveIssueSpy: ReturnType; - let getLatestEventSpy: ReturnType; - let listReplayIdsForIssueSpy: ReturnType; - - function createMockContext() { - const stdoutWrite = vi.fn(() => true); - return { - context: { - stdout: { write: stdoutWrite }, - stderr: { write: vi.fn(() => true) }, - cwd: "/tmp", - }, - stdoutWrite, - }; - } + let resolveIssueSpy: ReturnType; + let getLatestEventSpy: ReturnType; + let listReplayIdsForIssueSpy: ReturnType; beforeEach(() => { resolveIssueSpy = vi.spyOn(issueUtils, "resolveIssue"); @@ -101,16 +126,13 @@ describe("issue view replay integration", () => { const { context, stdoutWrite } = createMockContext(); const func = await viewCommand.loader(); - await func.call( - context, - { json: true, web: false, spans: 0, fresh: false }, - "CLI-123" - ); + await func.call(context, VIEW_FLAGS, "CLI-123"); const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); const parsed = JSON.parse(output); expect(parsed.org).toBe("test-org"); expect(parsed.replayIds).toEqual([REPLAY_ID, SECOND_REPLAY_ID]); + expect(Array.isArray(parsed)).toBe(false); }); test("renders additional related replays in human output", async () => { @@ -135,3 +157,229 @@ describe("issue view replay integration", () => { expect(output).toContain(`sentry replay view test-org/${SECOND_REPLAY_ID}`); }); }); + +describe("issue view multiple IDs", () => { + let resolveIssueSpy: ReturnType; + let getLatestEventSpy: ReturnType; + let listReplayIdsForIssueSpy: ReturnType; + let openInBrowserSpy: ReturnType; + + beforeEach(() => { + resolveIssueSpy = vi.spyOn(issueUtils, "resolveIssue"); + getLatestEventSpy = vi + .spyOn(apiClient, "getLatestEvent") + .mockResolvedValue(sampleEvent()); + listReplayIdsForIssueSpy = vi + .spyOn(apiClient, "listReplayIdsForIssue") + .mockResolvedValue([]); + openInBrowserSpy = vi + .spyOn(browser, "openInBrowser") + .mockResolvedValue(undefined); + }); + + afterEach(() => { + resolveIssueSpy.mockRestore(); + getLatestEventSpy.mockRestore(); + listReplayIdsForIssueSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + }); + + test("returns a JSON array for space-separated issue IDs", async () => { + resolveIssueSpy.mockImplementation( + async (options: { issueArg: string }) => ({ + org: "test-org", + issue: sampleIssue({ + id: options.issueArg, + shortId: options.issueArg, + title: options.issueArg, + }), + }) + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, VIEW_FLAGS, "IOS-1", "IOS-2"); + + expect(resolveIssueSpy).toHaveBeenCalledTimes(2); + const parsed = JSON.parse( + stdoutWrite.mock.calls.map((call) => call[0]).join("") + ); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[0].shortId).toBe("IOS-1"); + expect(parsed[1].shortId).toBe("IOS-2"); + }); + + test("expands newline-separated IDs from a single argument", async () => { + resolveIssueSpy.mockImplementation( + async (options: { issueArg: string }) => ({ + org: "test-org", + issue: sampleIssue({ + id: options.issueArg, + shortId: options.issueArg, + }), + }) + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, VIEW_FLAGS, "IOS-1\nIOS-2\nIOS-3"); + + expect(resolveIssueSpy).toHaveBeenCalledTimes(3); + const parsed = JSON.parse( + stdoutWrite.mock.calls.map((call) => call[0]).join("") + ); + expect(parsed.map((row: { shortId: string }) => row.shortId)).toEqual([ + "IOS-1", + "IOS-2", + "IOS-3", + ]); + }); + + test("does not split a comma-separated positional into multiple IDs", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: sampleIssue({ shortId: "IOS-1,IOS-2" }), + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, VIEW_FLAGS, "IOS-1,IOS-2"); + + expect(resolveIssueSpy).toHaveBeenCalledTimes(1); + expect(resolveIssueSpy).toHaveBeenCalledWith( + expect.objectContaining({ issueArg: "IOS-1,IOS-2" }) + ); + }); + + test("keeps JSON as an array when some IDs fail", async () => { + resolveIssueSpy.mockImplementation( + async (options: { issueArg: string }) => { + if (options.issueArg === "MISSING") { + throw new Error("not found"); + } + return { + org: "test-org", + issue: sampleIssue({ shortId: options.issueArg }), + }; + } + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, VIEW_FLAGS, "IOS-1", "MISSING"); + + const parsed = JSON.parse( + stdoutWrite.mock.calls.map((call) => call[0]).join("") + ); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + expect(parsed[0].shortId).toBe("IOS-1"); + }); + + test("throws when every requested issue fails", async () => { + const error = new Error("not found"); + resolveIssueSpy.mockRejectedValue(error); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await expect(func.call(context, VIEW_FLAGS, "IOS-1", "IOS-2")).rejects.toBe( + error + ); + }); + + test("throws ContextError when no issue ID is provided", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await expect(func.call(context, VIEW_FLAGS)).rejects.toThrow(ContextError); + }); + + test("--web opens only the first issue", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: sampleIssue(), + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: true, spans: 0, fresh: false }, + "IOS-1", + "IOS-2" + ); + + expect(resolveIssueSpy).toHaveBeenCalledTimes(1); + expect(openInBrowserSpy).toHaveBeenCalledWith( + sampleIssue().permalink, + "issue" + ); + }); +}); + +describe("fetchMultipleIssueViews", () => { + let resolveIssueSpy: ReturnType; + + beforeEach(() => { + resolveIssueSpy = vi.spyOn(issueUtils, "resolveIssue"); + vi.spyOn(apiClient, "getLatestEvent").mockResolvedValue(sampleEvent()); + vi.spyOn(apiClient, "listReplayIdsForIssue").mockResolvedValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("fetches multiple issues in parallel", async () => { + resolveIssueSpy.mockImplementation( + async (options: { issueArg: string }) => ({ + org: "test-org", + issue: sampleIssue({ shortId: options.issueArg }), + }) + ); + + const result = await fetchMultipleIssueViews({ + issueArgs: ["IOS-1", "IOS-2"], + cwd: "/tmp", + spans: 0, + }); + expect(result).toHaveLength(2); + expect(result[0]?.issue.shortId).toBe("IOS-1"); + expect(result[1]?.issue.shortId).toBe("IOS-2"); + }); + + test("warns on individual failures and continues", async () => { + resolveIssueSpy.mockImplementation( + async (options: { issueArg: string }) => { + if (options.issueArg === "IOS-2") { + throw new Error("not found"); + } + return { + org: "test-org", + issue: sampleIssue({ shortId: options.issueArg }), + }; + } + ); + + const result = await fetchMultipleIssueViews({ + issueArgs: ["IOS-1", "IOS-2"], + cwd: "/tmp", + spans: 0, + }); + expect(result).toHaveLength(1); + expect(result[0]?.issue.shortId).toBe("IOS-1"); + }); + + test("re-throws the primary error when all fetches fail", async () => { + const error = new Error("primary failed"); + resolveIssueSpy.mockRejectedValue(error); + + await expect( + fetchMultipleIssueViews({ + issueArgs: ["IOS-1", "IOS-2"], + cwd: "/tmp", + spans: 0, + }) + ).rejects.toBe(error); + }); +}); diff --git a/packages/cli/test/commands/issue/view.test.ts b/packages/cli/test/commands/issue/view.test.ts new file mode 100644 index 0000000000..ad15cbf463 --- /dev/null +++ b/packages/cli/test/commands/issue/view.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for issue view helpers: newline expansion, JSON shape, and + * human formatting. Command-body coverage lives in view.func.test.ts. + */ + +import { describe, expect, test } from "vitest"; +import { + collectIssueArgs, + expandNewlineArgs, + formatIssueView, + jsonTransformIssueView, +} from "../../../src/commands/issue/view.js"; +import type { SentryIssue } from "../../../src/types/index.js"; + +function sampleIssue(overrides: Partial = {}): SentryIssue { + return { + id: "12345", + shortId: "CLI-123", + title: "Replay-linked issue", + permalink: "https://sentry.io/organizations/test-org/issues/12345/", + ...overrides, + }; +} + +function sampleView(overrides: Partial = {}) { + return { + org: "test-org", + issue: sampleIssue(overrides), + event: null, + replayIds: [] as string[], + trace: null, + }; +} + +describe("expandNewlineArgs", () => { + test("expands newline-separated args into a flat array", () => { + expect(expandNewlineArgs(["IOS-1\nIOS-2\nIOS-3"])).toEqual([ + "IOS-1", + "IOS-2", + "IOS-3", + ]); + }); + + test("passes through args without newlines", () => { + expect(expandNewlineArgs(["IOS-1", "IOS-2"])).toEqual(["IOS-1", "IOS-2"]); + }); + + test("handles mixed args with and without newlines", () => { + expect(expandNewlineArgs(["IOS-1", "IOS-2\nIOS-3"])).toEqual([ + "IOS-1", + "IOS-2", + "IOS-3", + ]); + }); + + test("does not split on commas", () => { + expect(expandNewlineArgs(["IOS-1,IOS-2"])).toEqual(["IOS-1,IOS-2"]); + }); + + test("handles empty array", () => { + expect(expandNewlineArgs([])).toEqual([]); + }); +}); + +describe("collectIssueArgs", () => { + test("deduplicates while preserving first-seen order", () => { + expect(collectIssueArgs(["IOS-1", "IOS-2", "IOS-1"])).toEqual([ + "IOS-1", + "IOS-2", + ]); + }); + + test("deduplicates across newline expansion", () => { + expect(collectIssueArgs(["IOS-1\nIOS-2", "IOS-2"])).toEqual([ + "IOS-1", + "IOS-2", + ]); + }); +}); + +describe("formatIssueView", () => { + test("renders a single issue without a separator", () => { + const result = formatIssueView({ + issues: [sampleView()], + requestedCount: 1, + }); + expect(result).toContain("CLI-123"); + expect(result).not.toContain("---"); + }); + + test("renders multiple issues separated by a horizontal rule", () => { + const result = formatIssueView({ + issues: [ + sampleView({ shortId: "IOS-1", title: "First" }), + sampleView({ id: "2", shortId: "IOS-2", title: "Second" }), + ], + requestedCount: 2, + }); + expect(result).toContain("IOS-1"); + expect(result).toContain("---"); + expect(result).toContain("IOS-2"); + }); +}); + +describe("jsonTransformIssueView", () => { + test("returns a flat object for a single issue", () => { + const result = jsonTransformIssueView({ + issues: [sampleView()], + requestedCount: 1, + }); + expect(Array.isArray(result)).toBe(false); + expect(result).toEqual( + expect.objectContaining({ + shortId: "CLI-123", + org: "test-org", + event: null, + replayIds: [], + trace: null, + }) + ); + }); + + test("returns an array for multiple issues", () => { + const result = jsonTransformIssueView({ + issues: [ + sampleView({ shortId: "IOS-1" }), + sampleView({ id: "2", shortId: "IOS-2" }), + ], + requestedCount: 2, + }); + expect(Array.isArray(result)).toBe(true); + const arr = result as Record[]; + expect(arr).toHaveLength(2); + expect(arr[0]).toEqual(expect.objectContaining({ shortId: "IOS-1" })); + expect(arr[1]).toEqual(expect.objectContaining({ shortId: "IOS-2" })); + }); + + test("returns an array when multiple were requested but some failed", () => { + const result = jsonTransformIssueView({ + issues: [sampleView({ shortId: "IOS-1" })], + requestedCount: 3, + }); + expect(Array.isArray(result)).toBe(true); + const arr = result as Record[]; + expect(arr).toHaveLength(1); + expect(arr[0]).toEqual(expect.objectContaining({ shortId: "IOS-1" })); + }); + + test("applies field filtering for a single issue", () => { + const result = jsonTransformIssueView( + { + issues: [sampleView()], + requestedCount: 1, + }, + ["shortId"] + ); + expect(result).toEqual({ shortId: "CLI-123" }); + }); + + test("applies field filtering for multiple issues", () => { + const result = jsonTransformIssueView( + { + issues: [ + sampleView({ shortId: "IOS-1" }), + sampleView({ id: "2", shortId: "IOS-2" }), + ], + requestedCount: 2, + }, + ["shortId"] + ); + expect(result).toEqual([{ shortId: "IOS-1" }, { shortId: "IOS-2" }]); + }); +}); diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index 374ee8d5bc..5da21af978 100644 --- a/packages/cli/test/script/generate-skill-markdown.test.ts +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -10,6 +10,7 @@ import { describe("extractCommandPathFromHeading", () => { test.each([ ["`sentry issue view `", "sentry issue view"], + ["`sentry issue view `", "sentry issue view"], [ "`sentry project create [/]:...`", "sentry project create", From 80c97cdb43ac0439dece2df41f2a034946638397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 22 Sep 2026 20:30:26 +0200 Subject: [PATCH 2/2] feat(issue): extend batch workflows Open multiple issue pages with a five-tab safety cap, and allow an explicit --force override without repurposing the shared -f/--fresh convention. Add batch root-cause analysis while preserving every single-issue output shape. Extract issue rendering and share bounded batch orchestration to keep the command implementations focused. Co-authored-by: Cursor --- .../src/content/docs/agent-guidance.md | 1 + apps/cli-docs/src/fragments/commands/issue.md | 20 +- packages/cli/CONTRIBUTING.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 3 +- .../skills/sentry-cli/references/issue.md | 31 +- packages/cli/src/commands/issue/explain.ts | 126 ++++--- packages/cli/src/commands/issue/index.ts | 3 +- packages/cli/src/commands/issue/utils.ts | 75 +++- packages/cli/src/commands/issue/view.ts | 354 +++++------------- packages/cli/src/lib/formatters/index.ts | 1 + packages/cli/src/lib/formatters/issue.ts | 140 +++++++ packages/cli/src/lib/formatters/seer.ts | 98 ++++- .../test/commands/issue/explain.func.test.ts | 174 +++++++++ .../cli/test/commands/issue/view.func.test.ts | 63 +++- packages/cli/test/commands/issue/view.test.ts | 39 +- packages/cli/test/lib/formatters/seer.test.ts | 70 ++++ 16 files changed, 836 insertions(+), 364 deletions(-) create mode 100644 packages/cli/src/lib/formatters/issue.ts create mode 100644 packages/cli/test/commands/issue/explain.func.test.ts diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index c6acd86402..d9b4eb20eb 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -29,6 +29,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia - Use `--limit` to cap the number of results (default is usually 10–100) - Prefer `sentry issue view PROJECT-123` over listing and filtering manually - Pass multiple issue IDs in one call (`sentry issue view A B C --json`) instead of looping `issue view` per ID +- Analyze multiple issues in one call (`sentry issue explain A B C --json`) instead of looping `issue explain` per ID - Use `sentry api` for endpoints not covered by dedicated commands ## Safety Rules diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 86d76f571d..531e2c2be0 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -124,8 +124,11 @@ Latest event: ``` ```bash -# Open in browser -sentry issue view FRONT-ABC -w +# Open one or more issues in the browser (up to 5 tabs by default) +sentry issue view FRONT-ABC BACK-2 -w + +# Explicitly allow more than 5 tabs +sentry issue view FRONT-ABC BACK-2 API-3 WEB-4 IOS-5 OPS-6 -w --force ``` ```bash @@ -176,7 +179,7 @@ sentry issue view FRONT-ABC --json | jq '.event.entries[] | select(.type == "req sentry issue view FRONT-ABC --json | jq '.event.entries[] | select(.type == "exception") | .data.values[0] | {type, value}' ``` -### Explain and plan with Seer AI +### Explain issues with Seer AI ```bash # Analyze root cause (may take a few minutes for new issues) @@ -185,9 +188,20 @@ sentry issue explain 123456789 # By short ID with org prefix sentry issue explain my-org/MYPROJECT-ABC +# Analyze multiple issues in one invocation +sentry issue explain FRONT-ABC BACK-2 + # Force a fresh analysis sentry issue explain 123456789 --force +``` +With `--json`, one explained issue preserves the existing array of root causes. +Multiple issues return an array of labeled objects containing `issue`, `org`, +`issueId`, and `rootCauses`. + +### Generate a plan with Seer AI + +```bash # Generate a fix plan (automatically runs explain if needed) sentry issue plan 123456789 diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index b0c787611c..0616fef24b 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -37,7 +37,7 @@ sentry event view [/] [--json] [-w] # event ID requir **Key insight**: `org view` and `project view` mirror `gh repo view` - works in context (DSN) or with explicit arg. -**Browser flag**: All view commands support `-w` (or `--web`) to open the resource in your default browser instead of displaying it in the terminal. +**Browser flag**: All view commands support `-w` (or `--web`) to open the resource in your default browser instead of displaying it in the terminal. Batch `issue view` opens at most 5 tabs unless `--force` is passed. ## Context Resolution diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 2b8ee1e1b4..6851ca79f4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -41,6 +41,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia - Use `--limit` to cap the number of results (default is usually 10–100) - Prefer `sentry issue view PROJECT-123` over listing and filtering manually - Pass multiple issue IDs in one call (`sentry issue view A B C --json`) instead of looping `issue view` per ID +- Analyze multiple issues in one call (`sentry issue explain A B C --json`) instead of looping `issue explain` per ID - Use `sentry api` for endpoints not covered by dedicated commands ### Safety Rules @@ -409,7 +410,7 @@ Manage Sentry issues - `sentry issue list ` — List issues in a project - `sentry issue events ` — List events for a specific issue -- `sentry issue explain ` — Analyze an issue's root cause using Seer AI +- `sentry issue explain ` — Analyze one or more issues using Seer AI - `sentry issue plan ` — Generate a solution plan using Seer AI - `sentry issue view ` — View details of one or more issues - `sentry issue resolve ` — Mark an issue as resolved diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 3e3fcbf939..a6dce81d7c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -135,9 +135,9 @@ sentry issue events FRONT-ABC --limit 50 --period 24h sentry issue events FRONT-ABC -c next ``` -### `sentry issue explain ` +### `sentry issue explain ` -Analyze an issue's root cause using Seer AI +Analyze one or more issues using Seer AI **Flags:** - `--force - Force new analysis even if one exists` @@ -161,14 +161,11 @@ sentry issue explain 123456789 # By short ID with org prefix sentry issue explain my-org/MYPROJECT-ABC +# Analyze multiple issues in one invocation +sentry issue explain FRONT-ABC BACK-2 + # Force a fresh analysis sentry issue explain 123456789 --force - -# Generate a fix plan (automatically runs explain if needed) -sentry issue plan 123456789 - -# Force a fresh plan even if one already exists -sentry issue plan 123456789 --force ``` ### `sentry issue plan ` @@ -179,12 +176,23 @@ Generate a solution plan using Seer AI - `--force - Force new plan even if one exists` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +**Examples:** + +```bash +# Generate a fix plan (automatically runs explain if needed) +sentry issue plan 123456789 + +# Force a fresh plan even if one already exists +sentry issue plan 123456789 --force +``` + ### `sentry issue view ` View details of one or more issues **Flags:** - `-w, --web - Open in browser` +- `--force - Allow --web to open more than 5 issues` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -224,8 +232,11 @@ sentry issue view FRONT-ABC # Multiple issues in one invocation (space-separated, not commas) sentry issue view FRONT-ABC BACK-2 -# Open in browser -sentry issue view FRONT-ABC -w +# Open one or more issues in the browser (up to 5 tabs by default) +sentry issue view FRONT-ABC BACK-2 -w + +# Explicitly allow more than 5 tabs +sentry issue view FRONT-ABC BACK-2 API-3 WEB-4 IOS-5 OPS-6 -w --force # GitHub-style identifiers work too (the "#" replaces the final slash) sentry issue view my-org/my-project#FRONT-ABC diff --git a/packages/cli/src/commands/issue/explain.ts b/packages/cli/src/commands/issue/explain.ts index c76102b65d..a8f2467d32 100644 --- a/packages/cli/src/commands/issue/explain.ts +++ b/packages/cli/src/commands/issue/explain.ts @@ -6,24 +6,32 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { ApiError } from "../../lib/errors.js"; +import { ApiError, ContextError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { - formatRootCauseList, + formatIssueExplain, handleSeerApiError, + type IssueExplainResult, + jsonTransformIssueExplain, } from "../../lib/formatters/seer.js"; import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { logger } from "../../lib/logger.js"; import { extractRootCauses } from "../../types/seer.js"; import { + collectIssueArgs, ensureRootCauseAnalysis, - issueIdPositional, + issueIdsPositional, + mapIssueArgsConcurrently, resolveOrgAndIssueId, } from "./utils.js"; +const log = logger.withTag("issue.explain"); +const USAGE_HINT = "sentry issue explain [...]"; + type ExplainFlags = { readonly json: boolean; readonly force: boolean; @@ -31,11 +39,50 @@ type ExplainFlags = { readonly fields?: string[]; }; +async function analyzeIssue( + issueArg: string, + cwd: string, + flags: ExplainFlags, + suppressProgress: boolean +): Promise { + let resolvedOrg: string | undefined; + + try { + const { org, issueId } = await resolveOrgAndIssueId({ + issueArg, + cwd, + command: "explain", + }); + resolvedOrg = org; + + const state = await ensureRootCauseAnalysis({ + org, + issueId, + json: suppressProgress, + force: flags.force, + }); + const rootCauses = extractRootCauses(state); + if (rootCauses.length === 0) { + throw new Error( + "Analysis completed but no root causes found. " + + "The issue may not have enough context for root cause analysis." + ); + } + + return { issue: issueArg, org, issueId, rootCauses }; + } catch (error) { + if (error instanceof ApiError) { + throw handleSeerApiError(error.status, error.detail, resolvedOrg); + } + throw error; + } +} + export const explainCommand = buildCommand({ docs: { - brief: "Analyze an issue's root cause using Seer AI", + brief: "Analyze one or more issues using Seer AI", fullDescription: - "Get a root cause analysis for a Sentry issue using Seer AI.\n\n" + + "Get root cause analyses for one or more Sentry issues using Seer AI.\n\n" + "This command analyzes the issue and provides:\n" + " - Identified root causes\n" + " - Reproduction steps\n" + @@ -51,17 +98,23 @@ export const explainCommand = buildCommand({ " ID - Short ID: CLI-G (searches across orgs)\n" + " suffix - Suffix only: G (requires DSN context)\n" + " numeric - Numeric ID: 123456789\n\n" + + "Multiple issue IDs can be passed as separate arguments or newline-separated\n" + + "within a single argument.\n\n" + "Examples:\n" + " sentry issue explain @latest\n" + " sentry issue explain 123456789\n" + " sentry issue explain sentry/EXTENSION-7\n" + " sentry issue explain cli-G\n" + + " sentry issue explain CLI-G BACK-2\n" + " sentry issue explain 123456789 --json\n" + " sentry issue explain 123456789 --force", }, - output: { human: formatRootCauseList }, + output: { + human: formatIssueExplain, + jsonTransform: jsonTransformIssueExplain, + }, parameters: { - positional: issueIdPositional, + positional: issueIdsPositional, flags: { force: { kind: "boolean", @@ -72,47 +125,34 @@ export const explainCommand = buildCommand({ }, aliases: FRESH_ALIASES, }, - async *func(this: SentryContext, flags: ExplainFlags, issueArg: string) { + async *func(this: SentryContext, flags: ExplainFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd } = this; - // Declare org outside try block so it's accessible in catch for error messages - let resolvedOrg: string | undefined; - - try { - // Resolve org and issue ID - const { org, issueId: numericId } = await resolveOrgAndIssueId({ - issueArg, - cwd, - command: "explain", - }); - resolvedOrg = org; - - // Ensure root cause analysis exists (triggers if needed) - const state = await ensureRootCauseAnalysis({ - org, - issueId: numericId, - json: flags.json, - force: flags.force, - }); + const issueArgs = collectIssueArgs(args); + const [primaryArg] = issueArgs; + if (primaryArg === undefined) { + throw new ContextError("Issue ID", USAGE_HINT, []); + } - // Extract root causes from steps - const causes = extractRootCauses(state); - if (causes.length === 0) { - throw new Error( - "Analysis completed but no root causes found. " + - "The issue may not have enough context for root cause analysis." - ); + const isBatch = issueArgs.length > 1; + if (isBatch && !flags.json) { + log.info(`Analyzing ${issueArgs.length} issues...`); + } + const results = await mapIssueArgsConcurrently( + issueArgs, + (issueArg) => analyzeIssue(issueArg, cwd, flags, flags.json || isBatch), + (issueArg, reason) => { + log.warn(`Failed to analyze issue ${issueArg}: ${reason}`); } + ); - yield new CommandOutput(causes); - return { hint: `To create a plan, run: sentry issue plan ${issueArg}` }; - } catch (error) { - // Handle API errors with friendly messages - if (error instanceof ApiError) { - throw handleSeerApiError(error.status, error.detail, resolvedOrg); - } - throw error; - } + yield new CommandOutput({ + results, + requestedCount: issueArgs.length, + }); + return isBatch + ? undefined + : { hint: `To create a plan, run: sentry issue plan ${primaryArg}` }; }, }); diff --git a/packages/cli/src/commands/issue/index.ts b/packages/cli/src/commands/issue/index.ts index 33fb2b3e79..a141c6da08 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -32,7 +32,7 @@ export const issueRoute = buildRouteMap({ " list List issues in a project\n" + " events List events for a specific issue\n" + " view View details of one or more issues\n" + - " explain Analyze an issue using Seer AI\n" + + " explain Analyze one or more issues using Seer AI\n" + " plan Generate a solution plan using Seer AI\n" + " resolve Mark an issue as resolved (optionally in a release)\n" + " unresolve Reopen a resolved issue (alias: reopen)\n" + @@ -44,6 +44,7 @@ export const issueRoute = buildRouteMap({ "Examples:\n" + " sentry issue view @latest\n" + " sentry issue view FRONT-ABC BACK-2\n" + + " sentry issue explain FRONT-ABC BACK-2\n" + " sentry issue events CLI-G\n" + " sentry issue resolve CLI-12Z --in 0.26.1\n" + " sentry issue archive CLI-AB --until auto\n" + diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 7afb9fbe32..9bf58b1e6c 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -20,7 +20,11 @@ import { triggerRootCauseAnalysis, tryGetIssueByShortId, } from "../../lib/api-client.js"; -import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js"; +import { + type IssueSelector, + parseIssueArg, + splitNewlineArg, +} from "../../lib/arg-parsing.js"; import { clearCachedIssueOrg, getCachedIssueOrg, @@ -67,6 +71,75 @@ export const issueIdPositional = { ], } as const; +/** Variadic positional parameter for commands that accept multiple issues. */ +export const issueIdsPositional = { + kind: "array", + parameter: { + placeholder: "issue", + brief: "One or more issue IDs", + parse: String, + }, +} as const; + +/** + * Normalize variadic issue arguments. + * + * Newline-separated values are expanded for pasted or piped input. Duplicate + * tokens are removed while preserving the first-seen order. Commas remain + * part of the identifier, matching the CLI's positional-argument convention. + * + * @param args - Raw positional arguments + * @returns Normalized issue identifiers + */ +export function collectIssueArgs(args: readonly string[]): string[] { + return [...new Set(args.flatMap(splitNewlineArg))]; +} + +/** + * Map issue identifiers with the standard organization fan-out concurrency. + * + * Successful values preserve input order. Individual failures invoke + * `onError`; if every operation fails, the first error is rethrown. + * + * @param issueArgs - Normalized issue identifiers + * @param operation - Async work to perform for each identifier + * @param onError - Called for each failed identifier + * @returns Successful operation results in input order + */ +export async function mapIssueArgsConcurrently( + issueArgs: readonly string[], + operation: (issueArg: string) => Promise, + onError: (issueArg: string, reason: unknown) => void +): Promise { + const limit = pLimit(ORG_FANOUT_CONCURRENCY); + const settled = await Promise.allSettled( + issueArgs.map((issueArg) => limit(() => operation(issueArg))) + ); + + const values: T[] = []; + for (let index = 0; index < settled.length; index++) { + const result = settled[index]; + const issueArg = issueArgs[index]; + if (issueArg === undefined) { + continue; + } + if (result?.status === "fulfilled") { + values.push(result.value); + } else if (result?.status === "rejected") { + onError(issueArg, result.reason); + } + } + + if (values.length === 0) { + const first = settled[0]; + if (first?.status === "rejected") { + throw first.reason; + } + } + + return values; +} + /** * Build a command hint string for error messages. * diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index c3ba72a5b8..53051ab7f9 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -4,25 +4,18 @@ * View detailed information about one or more Sentry issues. */ -import pLimit from "p-limit"; import type { SentryContext } from "../../context.js"; -import { - getLatestEvent, - listReplayIdsForIssue, - ORG_FANOUT_CONCURRENCY, -} from "../../lib/api-client.js"; -import { spansFlag, splitNewlineArg } from "../../lib/arg-parsing.js"; +import { getLatestEvent, listReplayIdsForIssue } from "../../lib/api-client.js"; +import { spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; +import { plainSafeMuted } from "../../lib/formatters/human.js"; import { - formatEventDetails, - formatIssueDetails, - isPlainOutput, - muted, - renderMarkdown, -} from "../../lib/formatters/index.js"; -import { filterFields } from "../../lib/formatters/json.js"; + formatIssueView, + jsonTransformIssueView, + type SingleIssueViewData, +} from "../../lib/formatters/issue.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, @@ -35,18 +28,27 @@ import { getReplayIdFromEvent, } from "../../lib/replay-search.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; -import type { SentryEvent, SentryIssue } from "../../types/index.js"; +import type { SentryEvent } from "../../types/index.js"; import { IssueViewOutputSchema } from "../../types/index.js"; -import { resolveIssue } from "./utils.js"; +import { + collectIssueArgs, + issueIdsPositional, + mapIssueArgsConcurrently, + resolveIssue, +} from "./utils.js"; const log = logger.withTag("issue.view"); /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry issue view [...]"; +/** Maximum browser tabs opened without an explicit safety override. */ +export const MAX_WEB_ISSUES = 5; + type ViewFlags = { readonly json: boolean; readonly web: boolean; + readonly force: boolean; readonly spans: number; readonly fresh: boolean; readonly fields?: string[]; @@ -87,179 +89,40 @@ async function tryListReplayIdsForIssue( } } -/** Per-issue payload both renderers need */ -type SingleIssueViewData = { - org: string | null; - issue: SentryIssue; - event: SentryEvent | null; - replayIds: string[]; - trace: { traceId: string; spans: unknown[] } | null; - /** Pre-formatted span tree lines for human output (not serialized) */ - spanTreeLines?: string[]; -}; - -/** - * Output type for issue view — supports both single and multi-issue. - * Multi-issue output occurs when agents pass several IDs or paste - * newline-separated IDs (same contract as `event view`). - */ -type IssueViewData = { - issues: SingleIssueViewData[]; - /** Number of issues originally requested (before partial failures) */ - requestedCount: number; -}; - -const MAX_REPLAY_IDS_SHOWN = 3; - -function formatReplaySection(org: string | null, replayIds: string[]): string { - if (replayIds.length === 0) { - return ""; - } - - const visibleReplayIds = replayIds.slice(0, MAX_REPLAY_IDS_SHOWN); - const lines = ["### Related Replays", ""]; - - for (const replayId of visibleReplayIds) { - if (org) { - lines.push( - `- \`${replayId}\` (view: \`sentry replay view ${org}/${replayId}\`)` - ); - } else { - lines.push(`- \`${replayId}\``); - } - } - - const remainingCount = replayIds.length - visibleReplayIds.length; - if (remainingCount > 0) { - lines.push( - `- ${remainingCount} more related replay${remainingCount === 1 ? "" : "s"}` - ); - } - - return renderMarkdown(lines.join("\n")); -} - -/** - * Format one issue's view data for human-readable terminal output. - */ -function formatSingleIssueView(data: SingleIssueViewData): string { - const parts: string[] = []; - const eventReplayId = data.event - ? getReplayIdFromEvent(data.event) - : undefined; - - parts.push(formatIssueDetails(data.issue)); - - if (data.event) { - parts.push( - formatEventDetails(data.event, "Latest Event", data.issue.permalink) - ); - } - - const additionalReplayIds = eventReplayId - ? data.replayIds.filter((replayId) => replayId !== eventReplayId) - : data.replayIds; - const replaySection = formatReplaySection(data.org, additionalReplayIds); - if (replaySection) { - parts.push(replaySection); - } - - if (data.spanTreeLines && data.spanTreeLines.length > 0) { - parts.push(data.spanTreeLines.join("\n")); - } - - return parts.join("\n"); -} - -/** - * Format issue view data for human-readable terminal output. - * - * Renders issue details, optional latest event, and optional span tree. - * Multiple issues are separated by horizontal rules. - */ -export function formatIssueView(data: IssueViewData): string { - const parts: string[] = []; - - for (const entry of data.issues) { - if (parts.length > 0) { - parts.push("\n---\n"); - } - parts.push(formatSingleIssueView(entry)); - } - - return parts.join("\n"); -} +async function buildIssueSpanData( + orgSlug: string | undefined, + event: SentryEvent | undefined, + spans: number +): Promise> { + const spanTreeResult = + orgSlug && event && spans > 0 + ? await getSpanTreeLines(orgSlug, event, spans) + : undefined; -function flattenIssueView( - entry: SingleIssueViewData, - fields?: string[] -): Record { - const result: Record = { - ...entry.issue, - event: entry.event, - org: entry.org, - replayIds: entry.replayIds, - trace: entry.trace, - }; - if (fields && fields.length > 0) { - return filterFields(result, fields) as Record; + if (spanTreeResult) { + const trace = + spanTreeResult.success && spanTreeResult.traceId + ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans ?? [] } + : null; + return { trace, spanTreeLines: spanTreeResult.lines }; } - return result; -} - -/** - * Transform issue view data for JSON output. - * - * For single-issue output, flattens the issue as the primary object so that - * `--fields shortId,title` works directly on issue properties. The `event`, - * `trace`, `org`, and `replayIds` enrichment data are attached as sibling - * keys, accessible via `--fields event.id`, `--fields trace.traceId`, or - * `--fields replayIds`. - * - * For multi-issue output, returns an array of flattened issue objects. - * This preserves backward compatibility: single-issue callers still get - * a flat object, while multi-issue callers get an array. - * - * Use requestedCount (not issues.length) to decide the shape so that - * partial failures don't non-deterministically switch from array to object. - */ -export function jsonTransformIssueView( - data: IssueViewData, - fields?: string[] -): unknown { - if (data.requestedCount <= 1) { - const [first] = data.issues; - if (first) { - return flattenIssueView(first, fields); - } + if (!orgSlug) { + return { + trace: null, + spanTreeLines: [ + plainSafeMuted("\nOrganization context required to fetch span tree."), + ], + }; } - return data.issues.map((entry) => flattenIssueView(entry, fields)); -} - -/** - * Expand positional args by splitting each on newlines. - * - * When an agent pastes `"IOS-1\nIOS-2\nIOS-3"` as a single arg, this - * produces `["IOS-1", "IOS-2", "IOS-3"]`. Commas are left intact — - * positional values are space-separated, never comma-split. - */ -export function expandNewlineArgs(args: string[]): string[] { - return args.flatMap(splitNewlineArg); -} - -/** - * Expand newlines and drop duplicate tokens, preserving first-seen order. - */ -export function collectIssueArgs(args: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const arg of expandNewlineArgs(args)) { - if (!seen.has(arg)) { - seen.add(arg); - result.push(arg); - } + if (!event) { + return { + trace: null, + spanTreeLines: [ + plainSafeMuted("\nCould not fetch event to display span tree."), + ], + }; } - return result; + return { trace: null }; } /** @@ -286,41 +149,21 @@ async function buildSingleIssueViewData( event ? getReplayIdFromEvent(event) : undefined, ...relatedReplayIds, ]); - - let spanTreeResult: Awaited> | undefined; - if (orgSlug && event && spans > 0) { - spanTreeResult = await getSpanTreeLines(orgSlug, event, spans); - } - - let spanTreeLines: string[] | undefined; - if (spanTreeResult) { - spanTreeLines = spanTreeResult.lines; - } else if (!orgSlug) { - const msg = "\nOrganization context required to fetch span tree."; - spanTreeLines = [isPlainOutput() ? msg : muted(msg)]; - } else if (!event) { - const msg = "\nCould not fetch event to display span tree."; - spanTreeLines = [isPlainOutput() ? msg : muted(msg)]; - } - - const trace = spanTreeResult?.success - ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans } - : null; + const spanData = await buildIssueSpanData(orgSlug, event, spans); return { org: orgSlug ?? null, issue, event: event ?? null, replayIds, - trace, - spanTreeLines, + ...spanData, }; } /** Options for fetching multiple issues in parallel */ type FetchMultipleIssueViewsOptions = { /** Issue identifiers as provided on the command line */ - issueArgs: string[]; + issueArgs: readonly string[]; /** Working directory for DSN / project detection */ cwd: string; /** Span tree depth (`0` skips the fetch) */ @@ -331,41 +174,61 @@ type FetchMultipleIssueViewsOptions = { * Fetch multiple issues with bounded concurrency, collecting successes * and warning on failures. * - * Uses {@link ORG_FANOUT_CONCURRENCY} (5) to avoid overwhelming the API - * when agents paste dozens of IDs. Mirrors `event view`'s fetchMultipleEvents. + * Uses the shared issue-batch concurrency limit to avoid overwhelming the API + * when agents paste dozens of IDs. * * When all fetches fail, re-throws the error from the primary (first) issue. */ -export async function fetchMultipleIssueViews( +export function fetchMultipleIssueViews( options: FetchMultipleIssueViewsOptions ): Promise { const { issueArgs, cwd, spans } = options; - const limit = pLimit(ORG_FANOUT_CONCURRENCY); - - const results = await Promise.allSettled( - issueArgs.map((issueArg) => - limit(() => buildSingleIssueViewData(issueArg, cwd, spans)) - ) + return mapIssueArgsConcurrently( + issueArgs, + (issueArg) => buildSingleIssueViewData(issueArg, cwd, spans), + (issueArg, reason) => { + log.warn(`Failed to fetch issue ${issueArg}: ${reason}`); + } ); +} - const views: SingleIssueViewData[] = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result?.status === "fulfilled") { - views.push(result.value); - } else if (result?.status === "rejected") { - log.warn(`Failed to fetch issue ${issueArgs[i]}: ${result.reason}`); - } +/** + * Resolve and open issue browser pages, respecting the tab safety limit. + * + * @param issueArgs - Normalized issue identifiers + * @param cwd - Working directory for issue resolution + * @param force - Whether to bypass {@link MAX_WEB_ISSUES} + */ +async function openIssuesInBrowser( + issueArgs: readonly string[], + cwd: string, + force: boolean +): Promise { + const argsToOpen = force ? issueArgs : issueArgs.slice(0, MAX_WEB_ISSUES); + if (argsToOpen.length < issueArgs.length) { + log.warn( + `Opening the first ${MAX_WEB_ISSUES} of ${issueArgs.length} issues. Use --force to open all.` + ); } - if (views.length === 0) { - const firstResult = results[0]; - if (firstResult?.status === "rejected") { - throw firstResult.reason; + const issues = await mapIssueArgsConcurrently( + argsToOpen, + async (issueArg) => { + const { issue } = await resolveIssue({ + issueArg, + cwd, + command: "view", + }); + return issue; + }, + (issueArg, reason) => { + log.warn(`Failed to open issue ${issueArg}: ${reason}`); } - } + ); - return views; + for (const issue of issues) { + await openInBrowser(issue.permalink, "issue"); + } } export const viewCommand = buildCommand({ @@ -385,7 +248,8 @@ export const viewCommand = buildCommand({ " numeric - Numeric ID: 123456789\n" + " org/project#ID - GitHub-style: my-org/my-project#PROJ-123\n\n" + "Multiple issue IDs can be passed as separate arguments or newline-separated\n" + - "within a single argument (handy when piping from other commands).\n\n" + + "within a single argument (handy when piping from other commands).\n" + + `With --web, up to ${MAX_WEB_ISSUES} issues open by default; pass --force to open all.\n\n` + "In multi-project mode (after 'issue list'), use alias-suffix format (e.g., 'f-g' " + "where 'f' is the project alias shown in the list).", }, @@ -395,20 +259,18 @@ export const viewCommand = buildCommand({ schema: IssueViewOutputSchema, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "issue", - brief: " [...] - One or more issue IDs", - parse: String, - }, - }, + positional: issueIdsPositional, flags: { web: { kind: "boolean", brief: "Open in browser", default: false, }, + force: { + kind: "boolean", + brief: `Allow --web to open more than ${MAX_WEB_ISSUES} issues`, + default: false, + }, ...spansFlag, fresh: FRESH_FLAG, }, @@ -425,17 +287,7 @@ export const viewCommand = buildCommand({ } if (flags.web) { - if (issueArgs.length > 1) { - log.warn( - "--web only opens the first issue; extra issue IDs are ignored." - ); - } - const { issue } = await resolveIssue({ - issueArg: primaryArg, - cwd, - command: "view", - }); - await openInBrowser(issue.permalink, "issue"); + await openIssuesInBrowser(issueArgs, cwd, flags.force); return; } diff --git a/packages/cli/src/lib/formatters/index.ts b/packages/cli/src/lib/formatters/index.ts index adca23422e..a5390fe4a1 100644 --- a/packages/cli/src/lib/formatters/index.ts +++ b/packages/cli/src/lib/formatters/index.ts @@ -8,6 +8,7 @@ export * from "./colors.js"; export * from "./feedback.js"; export * from "./human.js"; +export * from "./issue.js"; export * from "./json.js"; export * from "./log.js"; export * from "./markdown.js"; diff --git a/packages/cli/src/lib/formatters/issue.ts b/packages/cli/src/lib/formatters/issue.ts new file mode 100644 index 0000000000..37ef3d7478 --- /dev/null +++ b/packages/cli/src/lib/formatters/issue.ts @@ -0,0 +1,140 @@ +/** + * Issue view output types and formatters. + */ + +import type { SentryEvent, SentryIssue } from "../../types/index.js"; +import { getReplayIdFromEvent } from "../replay-search.js"; +import { formatEventDetails, formatIssueDetails } from "./human.js"; +import { filterFields } from "./json.js"; +import { renderMarkdown } from "./markdown.js"; + +/** Data rendered for one issue. */ +export type SingleIssueViewData = { + /** Resolved organization slug, or null when resolution was unscoped. */ + org: string | null; + /** Full issue details. */ + issue: SentryIssue; + /** Latest event, or null when it could not be loaded. */ + event: SentryEvent | null; + /** Related Session Replay identifiers. */ + replayIds: string[]; + /** Trace context from the latest event, or null when unavailable. */ + trace: { traceId: string; spans: unknown[] } | null; + /** Pre-formatted span tree lines for human output only. */ + spanTreeLines?: string[]; +}; + +/** Aggregate issue view output for single- and multi-issue invocations. */ +export type IssueViewData = { + /** Successfully loaded issue views in request order. */ + issues: SingleIssueViewData[]; + /** Number of distinct issues requested before partial failures. */ + requestedCount: number; +}; + +const MAX_REPLAY_IDS_SHOWN = 3; + +function formatReplaySection(org: string | null, replayIds: string[]): string { + if (replayIds.length === 0) { + return ""; + } + + const visibleReplayIds = replayIds.slice(0, MAX_REPLAY_IDS_SHOWN); + const lines = ["### Related Replays", ""]; + + for (const replayId of visibleReplayIds) { + if (org) { + lines.push( + `- \`${replayId}\` (view: \`sentry replay view ${org}/${replayId}\`)` + ); + } else { + lines.push(`- \`${replayId}\``); + } + } + + const remainingCount = replayIds.length - visibleReplayIds.length; + if (remainingCount > 0) { + lines.push( + `- ${remainingCount} more related replay${remainingCount === 1 ? "" : "s"}` + ); + } + + return renderMarkdown(lines.join("\n")); +} + +function formatSingleIssueView(data: SingleIssueViewData): string { + const parts = [formatIssueDetails(data.issue)]; + const eventReplayId = data.event + ? getReplayIdFromEvent(data.event) + : undefined; + + if (data.event) { + parts.push( + formatEventDetails(data.event, "Latest Event", data.issue.permalink) + ); + } + + const additionalReplayIds = eventReplayId + ? data.replayIds.filter((replayId) => replayId !== eventReplayId) + : data.replayIds; + const replaySection = formatReplaySection(data.org, additionalReplayIds); + if (replaySection) { + parts.push(replaySection); + } + + if (data.spanTreeLines && data.spanTreeLines.length > 0) { + parts.push(data.spanTreeLines.join("\n")); + } + + return parts.join("\n"); +} + +/** + * Format one or more issue views for terminal output. + * + * @param data - Aggregate issue view output + * @returns Rendered issue sections separated by horizontal rules + */ +export function formatIssueView(data: IssueViewData): string { + return data.issues.map(formatSingleIssueView).join("\n\n---\n\n"); +} + +function flattenIssueView( + entry: SingleIssueViewData, + fields?: string[] +): Record { + const result: Record = { + ...entry.issue, + event: entry.event, + org: entry.org, + replayIds: entry.replayIds, + trace: entry.trace, + }; + return fields && fields.length > 0 + ? (filterFields(result, fields) as Record) + : result; +} + +/** + * Transform issue views for JSON output. + * + * A single requested issue preserves the historical flat-object shape. + * Multiple requested issues always produce an array, including after partial + * failures, so the output shape never depends on which request succeeded. + * + * @param data - Aggregate issue view output + * @param fields - Optional issue fields to retain + * @returns A flat issue object for one request, otherwise an array + */ +export function jsonTransformIssueView( + data: IssueViewData, + fields?: string[] +): unknown { + if (data.requestedCount <= 1) { + const [first] = data.issues; + if (first) { + return flattenIssueView(first, fields); + } + } + return data.issues.map((entry) => flattenIssueView(entry, fields)); +} diff --git a/packages/cli/src/lib/formatters/seer.ts b/packages/cli/src/lib/formatters/seer.ts index bc5fee9a39..30190b9ba8 100644 --- a/packages/cli/src/lib/formatters/seer.ts +++ b/packages/cli/src/lib/formatters/seer.ts @@ -12,6 +12,7 @@ import type { } from "../../types/seer.js"; import { ApiError, SeerError } from "../errors.js"; import { cyan } from "./colors.js"; +import { filterFields } from "./json.js"; import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; // Spinner Frames @@ -122,6 +123,26 @@ export function getProgressMessage(state: AutofixState): string { // Root Cause Formatting +/** Root-cause output for one requested issue. */ +export type IssueExplainResult = { + /** Issue identifier as supplied by the caller. */ + issue: string; + /** Resolved organization slug. */ + org: string; + /** Resolved numeric issue identifier. */ + issueId: string; + /** Root causes returned by Seer. */ + rootCauses: RootCause[]; +}; + +/** Aggregate root-cause output for single- and multi-issue invocations. */ +export type IssueExplainData = { + /** Successful issue analyses in request order. */ + results: IssueExplainResult[]; + /** Number of distinct issues requested before partial failures. */ + requestedCount: number; +}; + /** * Build a markdown document for a single root cause. * @@ -161,6 +182,23 @@ function buildRootCauseMarkdown(cause: RootCause, index: number): string { return lines.join("\n"); } +function buildRootCauseListMarkdown(causes: RootCause[]): string { + const lines = ["## Root Cause Analysis Complete", ""]; + + if (causes.length === 0) { + lines.push("*No root causes identified.*"); + } else { + for (let index = 0; index < causes.length; index++) { + const cause = causes[index]; + if (cause) { + lines.push(buildRootCauseMarkdown(cause, index)); + } + } + } + + return lines.join("\n"); +} + /** * Format all root causes as rendered terminal output. * @@ -168,23 +206,57 @@ function buildRootCauseMarkdown(cause: RootCause, index: number): string { * @returns Rendered terminal string */ export function formatRootCauseList(causes: RootCause[]): string { - const lines: string[] = []; + return renderMarkdown(buildRootCauseListMarkdown(causes)); +} - lines.push("## Root Cause Analysis Complete"); - lines.push(""); +/** + * Format one or more issue analyses for terminal output. + * + * @param data - Aggregate issue analysis output + * @returns The historical single-issue output or labeled issue sections + */ +export function formatIssueExplain(data: IssueExplainData): string { + if (data.requestedCount <= 1) { + return data.results[0] + ? formatRootCauseList(data.results[0].rootCauses) + : ""; + } - if (causes.length === 0) { - lines.push("*No root causes identified.*"); - } else { - for (let i = 0; i < causes.length; i++) { - const cause = causes[i]; - if (cause) { - lines.push(buildRootCauseMarkdown(cause, i)); - } - } + const sections = data.results.map( + (result) => + `# ${escapeMarkdownInline(result.issue)}\n\n${buildRootCauseListMarkdown(result.rootCauses)}` + ); + return renderMarkdown(sections.join("\n\n---\n\n")); +} + +/** + * Transform issue analyses for JSON output. + * + * A single requested issue preserves the historical array of root causes. + * Multiple issues return labeled envelopes so each cause remains attributable + * to its issue. Field filtering applies to each root-cause object. + * + * @param data - Aggregate issue analysis output + * @param fields - Optional root-cause fields to retain + * @returns Root causes for one issue, otherwise labeled result envelopes + */ +export function jsonTransformIssueExplain( + data: IssueExplainData, + fields?: string[] +): unknown { + const filterRootCauses = (rootCauses: RootCause[]): unknown => + fields && fields.length > 0 ? filterFields(rootCauses, fields) : rootCauses; + + if (data.requestedCount <= 1) { + return data.results[0] ? filterRootCauses(data.results[0].rootCauses) : []; } - return renderMarkdown(lines.join("\n")); + return data.results.map((result) => ({ + issue: result.issue, + org: result.org, + issueId: result.issueId, + rootCauses: filterRootCauses(result.rootCauses), + })); } // Error Messages diff --git a/packages/cli/test/commands/issue/explain.func.test.ts b/packages/cli/test/commands/issue/explain.func.test.ts new file mode 100644 index 0000000000..6407da5694 --- /dev/null +++ b/packages/cli/test/commands/issue/explain.func.test.ts @@ -0,0 +1,174 @@ +/** + * Tests for single- and multi-issue Seer root-cause analysis. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/commands/issue/utils.js") + >(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +import { explainCommand } from "../../../src/commands/issue/explain.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as issueUtils from "../../../src/commands/issue/utils.js"; +import { ContextError } from "../../../src/lib/errors.js"; +import type { AutofixState, RootCause } from "../../../src/types/seer.js"; + +const EXPLAIN_FLAGS = { + json: true, + force: false, + fresh: false, +} as const; + +function sampleCause(description: string): RootCause { + return { id: 0, description }; +} + +function sampleState(cause: RootCause): AutofixState { + return { + status: "COMPLETED", + steps: [ + { + id: "root-cause", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root cause", + causes: [cause], + }, + ], + }; +} + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +describe("issue explain multiple IDs", () => { + let resolveSpy: ReturnType; + let analyzeSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + resolveSpy = vi.spyOn(issueUtils, "resolveOrgAndIssueId"); + analyzeSpy = vi.spyOn(issueUtils, "ensureRootCauseAnalysis"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("preserves the JSON root-cause array for one issue", async () => { + resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" }); + analyzeSpy.mockResolvedValue(sampleState(sampleCause("Single cause"))); + + const { context, stdoutWrite } = createMockContext(); + const func = await explainCommand.loader(); + await func.call(context, EXPLAIN_FLAGS, "IOS-1"); + + expect(JSON.parse(stdoutWrite.mock.calls[0]?.[0] ?? "")).toEqual([ + expect.objectContaining({ description: "Single cause" }), + ]); + }); + + test("returns labeled JSON results for multiple issues", async () => { + resolveSpy.mockImplementation(async (options: { issueArg: string }) => ({ + org: "test-org", + issueId: options.issueArg === "IOS-1" ? "1" : "2", + })); + analyzeSpy.mockImplementation(async (options: { issueId: string }) => + sampleState(sampleCause(`Cause ${options.issueId}`)) + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await explainCommand.loader(); + await func.call(context, EXPLAIN_FLAGS, "IOS-1", "IOS-2"); + + expect(JSON.parse(stdoutWrite.mock.calls[0]?.[0] ?? "")).toEqual([ + { + issue: "IOS-1", + org: "test-org", + issueId: "1", + rootCauses: [expect.objectContaining({ description: "Cause 1" })], + }, + { + issue: "IOS-2", + org: "test-org", + issueId: "2", + rootCauses: [expect.objectContaining({ description: "Cause 2" })], + }, + ]); + }); + + test("suppresses per-issue progress for human batch output", async () => { + resolveSpy.mockImplementation(async (options: { issueArg: string }) => ({ + org: "test-org", + issueId: options.issueArg, + })); + analyzeSpy.mockResolvedValue(sampleState(sampleCause("Cause"))); + + const func = await explainCommand.loader(); + await func.call( + createMockContext().context, + { json: false, force: false, fresh: false }, + "IOS-1", + "IOS-2" + ); + + expect(analyzeSpy).toHaveBeenCalledTimes(2); + for (const [options] of analyzeSpy.mock.calls) { + expect(options).toEqual(expect.objectContaining({ json: true })); + } + }); + + test("keeps batch JSON shape after a partial failure", async () => { + resolveSpy.mockImplementation(async (options: { issueArg: string }) => { + if (options.issueArg === "MISSING") { + throw new Error("not found"); + } + return { org: "test-org", issueId: "1" }; + }); + analyzeSpy.mockResolvedValue(sampleState(sampleCause("Cause"))); + + const { context, stdoutWrite } = createMockContext(); + const func = await explainCommand.loader(); + await func.call(context, EXPLAIN_FLAGS, "IOS-1", "MISSING"); + + const output = JSON.parse(stdoutWrite.mock.calls[0]?.[0] ?? ""); + expect(output).toHaveLength(1); + expect(output[0]).toEqual(expect.objectContaining({ issue: "IOS-1" })); + }); + + test("rethrows the primary error when every issue fails", async () => { + const error = new Error("analysis failed"); + resolveSpy.mockRejectedValue(error); + + const func = await explainCommand.loader(); + await expect( + func.call(createMockContext().context, EXPLAIN_FLAGS, "IOS-1", "IOS-2") + ).rejects.toBe(error); + }); + + test("throws ContextError when no issue ID is provided", async () => { + const func = await explainCommand.loader(); + await expect( + func.call(createMockContext().context, EXPLAIN_FLAGS) + ).rejects.toThrow(ContextError); + }); +}); diff --git a/packages/cli/test/commands/issue/view.func.test.ts b/packages/cli/test/commands/issue/view.func.test.ts index 3f73a9f03a..0880efa9bc 100644 --- a/packages/cli/test/commands/issue/view.func.test.ts +++ b/packages/cli/test/commands/issue/view.func.test.ts @@ -21,6 +21,7 @@ vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => { import * as issueUtils from "../../../src/commands/issue/utils.js"; import { fetchMultipleIssueViews, + MAX_WEB_ISSUES, viewCommand, } from "../../../src/commands/issue/view.js"; @@ -92,6 +93,7 @@ function createMockContext() { const VIEW_FLAGS = { json: true, web: false, + force: false, spans: 0, fresh: false, } as const; @@ -147,7 +149,7 @@ describe("issue view replay integration", () => { const func = await viewCommand.loader(); await func.call( context, - { json: false, web: false, spans: 0, fresh: false }, + { json: false, web: false, force: false, spans: 0, fresh: false }, "CLI-123" ); @@ -294,27 +296,68 @@ describe("issue view multiple IDs", () => { await expect(func.call(context, VIEW_FLAGS)).rejects.toThrow(ContextError); }); - test("--web opens only the first issue", async () => { - resolveIssueSpy.mockResolvedValue({ - org: "test-org", - issue: sampleIssue(), - }); + test("--web opens every issue within the safety limit", async () => { + resolveIssueSpy.mockImplementation( + async (options: { issueArg: string }) => ({ + org: "test-org", + issue: sampleIssue({ + shortId: options.issueArg, + permalink: `https://sentry.io/issues/${options.issueArg}/`, + }), + }) + ); const { context } = createMockContext(); const func = await viewCommand.loader(); await func.call( context, - { json: false, web: true, spans: 0, fresh: false }, + { json: false, web: true, force: false, spans: 0, fresh: false }, "IOS-1", "IOS-2" ); - expect(resolveIssueSpy).toHaveBeenCalledTimes(1); - expect(openInBrowserSpy).toHaveBeenCalledWith( - sampleIssue().permalink, + expect(resolveIssueSpy).toHaveBeenCalledTimes(2); + expect(openInBrowserSpy).toHaveBeenNthCalledWith( + 1, + "https://sentry.io/issues/IOS-1/", + "issue" + ); + expect(openInBrowserSpy).toHaveBeenNthCalledWith( + 2, + "https://sentry.io/issues/IOS-2/", "issue" ); }); + + test("--web caps opened issues unless --force is passed", async () => { + resolveIssueSpy.mockImplementation( + async (options: { issueArg: string }) => ({ + org: "test-org", + issue: sampleIssue({ shortId: options.issueArg }), + }) + ); + const issueArgs = Array.from( + { length: MAX_WEB_ISSUES + 2 }, + (_, index) => `IOS-${index + 1}` + ); + + const func = await viewCommand.loader(); + await func.call( + createMockContext().context, + { json: false, web: true, force: false, spans: 0, fresh: false }, + ...issueArgs + ); + expect(openInBrowserSpy).toHaveBeenCalledTimes(MAX_WEB_ISSUES); + + openInBrowserSpy.mockClear(); + resolveIssueSpy.mockClear(); + await func.call( + createMockContext().context, + { json: false, web: true, force: true, spans: 0, fresh: false }, + ...issueArgs + ); + expect(openInBrowserSpy).toHaveBeenCalledTimes(issueArgs.length); + }); }); describe("fetchMultipleIssueViews", () => { diff --git a/packages/cli/test/commands/issue/view.test.ts b/packages/cli/test/commands/issue/view.test.ts index ad15cbf463..0596e7dd10 100644 --- a/packages/cli/test/commands/issue/view.test.ts +++ b/packages/cli/test/commands/issue/view.test.ts @@ -4,12 +4,11 @@ */ import { describe, expect, test } from "vitest"; +import { collectIssueArgs } from "../../../src/commands/issue/utils.js"; import { - collectIssueArgs, - expandNewlineArgs, formatIssueView, jsonTransformIssueView, -} from "../../../src/commands/issue/view.js"; +} from "../../../src/lib/formatters/issue.js"; import type { SentryIssue } from "../../../src/types/index.js"; function sampleIssue(overrides: Partial = {}): SentryIssue { @@ -32,21 +31,17 @@ function sampleView(overrides: Partial = {}) { }; } -describe("expandNewlineArgs", () => { - test("expands newline-separated args into a flat array", () => { - expect(expandNewlineArgs(["IOS-1\nIOS-2\nIOS-3"])).toEqual([ +describe("collectIssueArgs", () => { + test("expands newline-separated args", () => { + expect(collectIssueArgs(["IOS-1\nIOS-2\nIOS-3"])).toEqual([ "IOS-1", "IOS-2", "IOS-3", ]); }); - test("passes through args without newlines", () => { - expect(expandNewlineArgs(["IOS-1", "IOS-2"])).toEqual(["IOS-1", "IOS-2"]); - }); - - test("handles mixed args with and without newlines", () => { - expect(expandNewlineArgs(["IOS-1", "IOS-2\nIOS-3"])).toEqual([ + test("handles mixed arguments and removes duplicates", () => { + expect(collectIssueArgs(["IOS-1", "IOS-2\nIOS-3", "IOS-2"])).toEqual([ "IOS-1", "IOS-2", "IOS-3", @@ -54,27 +49,11 @@ describe("expandNewlineArgs", () => { }); test("does not split on commas", () => { - expect(expandNewlineArgs(["IOS-1,IOS-2"])).toEqual(["IOS-1,IOS-2"]); + expect(collectIssueArgs(["IOS-1,IOS-2"])).toEqual(["IOS-1,IOS-2"]); }); test("handles empty array", () => { - expect(expandNewlineArgs([])).toEqual([]); - }); -}); - -describe("collectIssueArgs", () => { - test("deduplicates while preserving first-seen order", () => { - expect(collectIssueArgs(["IOS-1", "IOS-2", "IOS-1"])).toEqual([ - "IOS-1", - "IOS-2", - ]); - }); - - test("deduplicates across newline expansion", () => { - expect(collectIssueArgs(["IOS-1\nIOS-2", "IOS-2"])).toEqual([ - "IOS-1", - "IOS-2", - ]); + expect(collectIssueArgs([])).toEqual([]); }); }); diff --git a/packages/cli/test/lib/formatters/seer.test.ts b/packages/cli/test/lib/formatters/seer.test.ts index 1ae23c2f4e..588c707700 100644 --- a/packages/cli/test/lib/formatters/seer.test.ts +++ b/packages/cli/test/lib/formatters/seer.test.ts @@ -9,12 +9,14 @@ import { SeerError } from "../../../src/lib/errors.js"; import { createSeerError, formatAutofixError, + formatIssueExplain, formatProgressLine, formatRootCauseList, formatSolution, getProgressMessage, getSpinnerFrame, handleSeerApiError, + jsonTransformIssueExplain, truncateProgressMessage, } from "../../../src/lib/formatters/seer.js"; import type { @@ -287,6 +289,74 @@ describe("formatRootCauseList", () => { }); }); +describe("issue explain batch formatting", () => { + const data = { + results: [ + { + issue: "IOS-1", + org: "test-org", + issueId: "1", + rootCauses: [{ id: 0, description: "First cause" }], + }, + { + issue: "IOS-2", + org: "test-org", + issueId: "2", + rootCauses: [{ id: 0, description: "Second cause" }], + }, + ], + requestedCount: 2, + }; + + test("labels each issue in human output", () => { + const output = stripAnsi(formatIssueExplain(data)); + expect(output).toContain("IOS-1"); + expect(output).toContain("First cause"); + expect(output).toContain("─"); + expect(output).toContain("IOS-2"); + expect(output).toContain("Second cause"); + }); + + test("returns labeled envelopes in multi-issue JSON", () => { + expect(jsonTransformIssueExplain(data)).toEqual([ + expect.objectContaining({ + issue: "IOS-1", + rootCauses: [expect.objectContaining({ description: "First cause" })], + }), + expect.objectContaining({ + issue: "IOS-2", + rootCauses: [expect.objectContaining({ description: "Second cause" })], + }), + ]); + }); + + test("preserves the root-cause array for single-issue JSON", () => { + expect( + jsonTransformIssueExplain({ + results: data.results.slice(0, 1), + requestedCount: 1, + }) + ).toEqual([expect.objectContaining({ description: "First cause" })]); + }); + + test("filters root-cause fields without dropping issue labels", () => { + expect(jsonTransformIssueExplain(data, ["description"])).toEqual([ + { + issue: "IOS-1", + org: "test-org", + issueId: "1", + rootCauses: [{ description: "First cause" }], + }, + { + issue: "IOS-2", + org: "test-org", + issueId: "2", + rootCauses: [{ description: "Second cause" }], + }, + ]); + }); +}); + describe("formatAutofixError", () => { // Note: 402 and 403 errors are handled by SeerError via createSeerError() // formatAutofixError only handles non-Seer errors