diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 05203ce21..d9b4eb20e 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,8 @@ 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 +- 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 41fd673b0..531e2c2be 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 ``` ``` @@ -121,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 @@ -135,12 +141,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 @@ -169,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) @@ -178,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 154407f46..0616fef24 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -31,13 +31,13 @@ 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 ``` **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 66c6161ba..6851ca79f 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,8 @@ 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 +- 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 @@ -408,9 +410,9 @@ 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 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 a682602fa..a6dce81d7 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` -### `sentry issue view ` +**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 a specific issue +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` @@ -221,8 +229,14 @@ View details of a specific issue ```bash sentry issue view FRONT-ABC -# Open in browser -sentry issue view FRONT-ABC -w +# Multiple issues in one invocation (space-separated, not commas) +sentry issue view FRONT-ABC BACK-2 + +# 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 @@ -231,6 +245,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/explain.ts b/packages/cli/src/commands/issue/explain.ts index c76102b65..a8f2467d3 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 09b72e94b..a141c6da0 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -31,8 +31,8 @@ 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" + - " explain Analyze an issue using Seer AI\n" + + " view View details of one or more issues\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" + @@ -43,6 +43,8 @@ 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 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 7afb9fbe3..9bf58b1e6 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 ba5e6fcf4..53051ab7f 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -1,7 +1,7 @@ /** * sentry issue view * - * View detailed information about a Sentry issue. + * View detailed information about one or more Sentry issues. */ import type { SentryContext } from "../../context.js"; @@ -9,14 +9,13 @@ 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, @@ -29,15 +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 { issueIdPositional, 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[]; @@ -78,116 +89,153 @@ async function tryListReplayIdsForIssue( } } -/** Return type for issue view — includes all data both renderers need */ -type IssueViewData = { - 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[]; -}; +async function buildIssueSpanData( + orgSlug: string | undefined, + event: SentryEvent | undefined, + spans: number +): Promise> { + const spanTreeResult = + orgSlug && event && spans > 0 + ? await getSpanTreeLines(orgSlug, event, spans) + : undefined; -const MAX_REPLAY_IDS_SHOWN = 3; - -function formatReplaySection(org: string | null, replayIds: string[]): string { - if (replayIds.length === 0) { - return ""; + if (spanTreeResult) { + const trace = + spanTreeResult.success && spanTreeResult.traceId + ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans ?? [] } + : null; + return { trace, spanTreeLines: spanTreeResult.lines }; } - - 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}\``); - } + if (!orgSlug) { + return { + trace: null, + spanTreeLines: [ + plainSafeMuted("\nOrganization context required to fetch span tree."), + ], + }; } - - const remainingCount = replayIds.length - visibleReplayIds.length; - if (remainingCount > 0) { - lines.push( - `- ${remainingCount} more related replay${remainingCount === 1 ? "" : "s"}` - ); + if (!event) { + return { + trace: null, + spanTreeLines: [ + plainSafeMuted("\nCould not fetch event to display span tree."), + ], + }; } - - return renderMarkdown(lines.join("\n")); + return { trace: null }; } /** - * Format issue view data for human-readable terminal output. - * - * Renders issue details, optional latest event, and optional span tree. + * Resolve one issue and attach latest event, replays, and optional span tree. */ -function formatIssueView(data: IssueViewData): string { - const parts: string[] = []; - const eventReplayId = data.event - ? getReplayIdFromEvent(data.event) - : undefined; +async function buildSingleIssueViewData( + issueArg: string, + cwd: string, + spans: number +): Promise { + const { org: orgSlug, issue } = await resolveIssue({ + issueArg, + cwd, + command: "view", + }); - parts.push(formatIssueDetails(data.issue)); + const [event, relatedReplayIds] = orgSlug + ? await Promise.all([ + tryGetLatestEvent(orgSlug, issue.id), + tryListReplayIdsForIssue(orgSlug, issue.id), + ]) + : [undefined, []]; + const replayIds = collectReplayIds([ + event ? getReplayIdFromEvent(event) : undefined, + ...relatedReplayIds, + ]); + const spanData = await buildIssueSpanData(orgSlug, event, spans); - 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); - } + return { + org: orgSlug ?? null, + issue, + event: event ?? null, + replayIds, + ...spanData, + }; +} - if (data.spanTreeLines && data.spanTreeLines.length > 0) { - parts.push(data.spanTreeLines.join("\n")); - } +/** Options for fetching multiple issues in parallel */ +type FetchMultipleIssueViewsOptions = { + /** Issue identifiers as provided on the command line */ + issueArgs: readonly string[]; + /** Working directory for DSN / project detection */ + cwd: string; + /** Span tree depth (`0` skips the fetch) */ + spans: number; +}; - return parts.join("\n"); +/** + * Fetch multiple issues with bounded concurrency, collecting successes + * and warning on failures. + * + * 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 function fetchMultipleIssueViews( + options: FetchMultipleIssueViewsOptions +): Promise { + const { issueArgs, cwd, spans } = options; + return mapIssueArgsConcurrently( + issueArgs, + (issueArg) => buildSingleIssueViewData(issueArg, cwd, spans), + (issueArg, reason) => { + log.warn(`Failed to fetch issue ${issueArg}: ${reason}`); + } + ); } /** - * Transform issue view data for JSON output. + * Resolve and open issue browser pages, respecting the tab safety limit. * - * 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`. - * - * Without this transform, `--fields shortId` would return `{}` because - * the raw yield shape is `{ issue, event, trace }` and `shortId` lives - * inside `issue`. + * @param issueArgs - Normalized issue identifiers + * @param cwd - Working directory for issue resolution + * @param force - Whether to bypass {@link MAX_WEB_ISSUES} */ -function jsonTransformIssueView( - data: IssueViewData, - fields?: string[] -): unknown { - const { issue, event, org, replayIds, trace } = data; - const result: Record = { - ...issue, - event, - org, - replayIds, - trace, - }; - if (fields && fields.length > 0) { - return filterFields(result, fields); +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.` + ); + } + + 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}`); + } + ); + + for (const issue of issues) { + await openInBrowser(issue.permalink, "issue"); } - return result; } 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 +247,9 @@ 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" + + `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).", }, @@ -208,81 +259,50 @@ export const viewCommand = buildCommand({ schema: IssueViewOutputSchema, }, parameters: { - positional: issueIdPositional, + 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, }, 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) { - await openInBrowser(issue.permalink, "issue"); + await openIssuesInBrowser(issueArgs, cwd, flags.force); 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/src/lib/formatters/index.ts b/packages/cli/src/lib/formatters/index.ts index adca23422..a5390fe4a 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 000000000..37ef3d747 --- /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 bc5fee9a3..30190b9ba 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 000000000..6407da569 --- /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 bbb81ea63..0880efa9b 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,11 @@ 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, + MAX_WEB_ISSUES, + viewCommand, +} from "../../../src/commands/issue/view.js"; vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { const actual = @@ -34,6 +38,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 +78,30 @@ 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, + force: 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 +128,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 () => { @@ -125,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" ); @@ -135,3 +159,270 @@ 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 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, force: false, spans: 0, fresh: false }, + "IOS-1", + "IOS-2" + ); + + 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", () => { + 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 000000000..0596e7dd1 --- /dev/null +++ b/packages/cli/test/commands/issue/view.test.ts @@ -0,0 +1,152 @@ +/** + * 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 } from "../../../src/commands/issue/utils.js"; +import { + formatIssueView, + jsonTransformIssueView, +} from "../../../src/lib/formatters/issue.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("collectIssueArgs", () => { + test("expands newline-separated args", () => { + expect(collectIssueArgs(["IOS-1\nIOS-2\nIOS-3"])).toEqual([ + "IOS-1", + "IOS-2", + "IOS-3", + ]); + }); + + test("handles mixed arguments and removes duplicates", () => { + expect(collectIssueArgs(["IOS-1", "IOS-2\nIOS-3", "IOS-2"])).toEqual([ + "IOS-1", + "IOS-2", + "IOS-3", + ]); + }); + + test("does not split on commas", () => { + expect(collectIssueArgs(["IOS-1,IOS-2"])).toEqual(["IOS-1,IOS-2"]); + }); + + test("handles empty array", () => { + expect(collectIssueArgs([])).toEqual([]); + }); +}); + +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/lib/formatters/seer.test.ts b/packages/cli/test/lib/formatters/seer.test.ts index 1ae23c2f4..588c70770 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 diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index 374ee8d5b..5da21af97 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",