diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 8d133d7e4..df3b1136c 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -22,6 +22,8 @@ import { import { logger } from "../../lib/logger.js"; import { type AutofixState, + extractExaminedFiles, + extractNoSolutionReason, extractRootCauses, extractSolution, type RootCause, @@ -107,24 +109,91 @@ function validateCauseSelection( return causeId; } +/** Context about why no solution was produced */ +type NoSolutionContext = { + /** Seer's reason for not producing a solution (from the artifact) */ + reason?: string; + /** Root cause description that was analyzed */ + root_cause?: string; + /** Files Seer examined during analysis */ + files_examined?: string[]; +}; + /** Return type for issue plan — includes state metadata and solution data */ type PlanData = { run_id: number; status: string; /** The solution data (without the artifact wrapper). Null when no solution is available. */ solution: SolutionArtifact["data"] | null; + /** Context about why no solution was produced. Only present when solution is null. */ + no_solution_context?: NoSolutionContext; }; /** * Format solution plan data for human-readable terminal output. * - * Returns the formatted solution or a "no solution" message. + * Returns the formatted solution, or a contextual message explaining + * why no solution was produced. */ function formatPlanOutput(data: PlanData): string { if (data.solution) { return formatSolution({ key: "solution", data: data.solution }); } - return "No solution found. Check the Sentry web UI for details."; + + const lines: string[] = []; + const ctx = data.no_solution_context; + + if (ctx?.reason) { + lines.push(`No solution found: ${ctx.reason}`); + } else { + lines.push( + "No solution found. Seer completed analysis but could not identify a code fix." + ); + if (ctx?.root_cause) { + lines.push(""); + lines.push(`Root cause analyzed: ${ctx.root_cause}`); + } + } + + if (ctx?.files_examined && ctx.files_examined.length > 0) { + lines.push(""); + lines.push("Files examined:"); + for (const file of ctx.files_examined) { + lines.push(` ${file}`); + } + } + + return lines.join("\n"); +} + +/** + * Gather context about why Seer produced no solution. + * + * Returns undefined when there's nothing useful to report. + */ +function buildNoSolutionContext( + state: AutofixState, + selectedCause?: RootCause +): NoSolutionContext | undefined { + const reason = extractNoSolutionReason(state); + const cause = selectedCause ?? extractRootCauses(state)[0]; + const files = cause ? extractExaminedFiles([cause]) : []; + + if (!(reason || cause?.description) && files.length === 0) { + return; + } + + const ctx: NoSolutionContext = {}; + if (reason) { + ctx.reason = reason; + } + if (cause?.description) { + ctx.root_cause = cause.description; + } + if (files.length > 0) { + ctx.files_examined = files; + } + return ctx; } /** @@ -132,14 +201,27 @@ function formatPlanOutput(data: PlanData): string { * * Stores `solution.data` (not the full artifact) to keep the JSON shape flat — * consumers get `{ run_id, status, solution: { one_line_summary, steps, ... } }`. + * + * When no solution is available, includes context about why (reason from the + * API, root cause description, and files examined) so the user isn't left + * with a bare "no solution found" message. */ -function buildPlanData(state: AutofixState): PlanData { +function buildPlanData( + state: AutofixState, + selectedCause?: RootCause +): PlanData { const solution = extractSolution(state); - return { + const data: PlanData = { run_id: state.run_id, status: state.status, solution: solution?.data ?? null, }; + + if (!solution) { + data.no_solution_context = buildNoSolutionContext(state, selectedCause); + } + + return data; } export const planCommand = buildCommand({ @@ -217,34 +299,43 @@ export const planCommand = buildCommand({ // Validate we have root causes const causes = validateRootCauses(state); - // Validate cause selection - const causeId = validateCauseSelection(causes, flags.cause, issueArg); - const selectedCause = causes[causeId]; + // Validate cause selection (always returns a valid index into causes) + const causeIndex = validateCauseSelection(causes, flags.cause, issueArg); + const selectedCause = causes.at(causeIndex); + if (!selectedCause) { + throw new ValidationError( + `Invalid cause index: ${causeIndex}. Valid range is 0-${causes.length - 1}.` + ); + } // Check if solution already exists (skip if --force) if (!flags.force) { const existingSolution = extractSolution(state); if (existingSolution) { - return yield new CommandOutput(buildPlanData(state)); + return yield new CommandOutput(buildPlanData(state, selectedCause)); } } // No solution exists, trigger planning if (!flags.json) { const log = logger.withTag("issue.plan"); - log.info(`Creating plan for cause #${causeId}...`); - if (selectedCause) { - log.info(`"${selectedCause.description}"`); - } + log.info(`Creating plan for cause #${causeIndex}...`); + log.info(`"${selectedCause.description}"`); } - await triggerSolutionPlanning(org, numericId, state.run_id); + await triggerSolutionPlanning( + org, + numericId, + state.run_id, + selectedCause.id + ); - // Poll until PR is created + // Poll until solution is ready (NEED_MORE_INFORMATION) or terminal const finalState = await pollAutofixState({ orgSlug: org, issueId: numericId, json: flags.json, + stopOnWaitingForUser: true, timeoutMessage: "Plan creation timed out after 6 minutes. Try again or check the issue in Sentry web UI.", timeoutHint: @@ -263,7 +354,7 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } - return yield new CommandOutput(buildPlanData(finalState)); + return yield new CommandOutput(buildPlanData(finalState, selectedCause)); } catch (error) { // Handle API errors with friendly messages if (error instanceof ApiError) { diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 1331eae03..f2cbff001 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -927,8 +927,13 @@ export async function ensureRootCauseAnalysis( /** * Check if polling should stop based on current state. * + * Terminal statuses (COMPLETED, ERROR, CANCELLED) always stop polling. + * When `stopOnWaitingForUser` is true, also stops on interactive statuses: + * - `WAITING_FOR_USER_RESPONSE` — root cause analysis needs user input + * - `NEED_MORE_INFORMATION` — solution step completed, awaiting user decision + * * @param state - Current autofix state - * @param stopOnWaitingForUser - Whether to stop on WAITING_FOR_USER_RESPONSE status + * @param stopOnWaitingForUser - Whether to stop on interactive statuses * @returns True if polling should stop */ function shouldStopPolling( @@ -938,7 +943,11 @@ function shouldStopPolling( if (isTerminalStatus(state.status)) { return true; } - if (stopOnWaitingForUser && state.status === "WAITING_FOR_USER_RESPONSE") { + if ( + stopOnWaitingForUser && + (state.status === "WAITING_FOR_USER_RESPONSE" || + state.status === "NEED_MORE_INFORMATION") + ) { return true; } return false; diff --git a/src/lib/api/seer.ts b/src/lib/api/seer.ts index 816920534..ab986a6ff 100644 --- a/src/lib/api/seer.ts +++ b/src/lib/api/seer.ts @@ -76,28 +76,37 @@ export async function getAutofixState( /** * Trigger solution planning for an existing autofix run. - * Uses region-aware routing for multi-region support. + * + * Sends a `select_root_cause` update with `stopping_point: "solution"` to the + * autofix update endpoint. This tells Seer to proceed from root cause analysis + * to generating a solution plan. * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID * @param runId - The autofix run ID + * @param causeId - The root cause ID to plan a solution for * @returns The response from the API */ export async function triggerSolutionPlanning( orgSlug: string, issueId: string, - runId: number + runId: number, + causeId: number ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); const { data } = await apiRequestToRegion( regionUrl, - `/organizations/${orgSlug}/issues/${issueId}/autofix/`, + `/organizations/${orgSlug}/issues/${issueId}/autofix/update/`, { method: "POST", body: { run_id: runId, - step: "solution", + payload: { + type: "select_root_cause", + cause_id: causeId, + stopping_point: "solution", + }, }, } ); diff --git a/src/types/seer.ts b/src/types/seer.ts index 21c09a246..28c0101e9 100644 --- a/src/types/seer.ts +++ b/src/types/seer.ts @@ -242,20 +242,47 @@ export function isTerminalStatus(status: string): boolean { return TERMINAL_STATUSES.includes(status as AutofixStatus); } +/** Container that may hold root cause analysis data */ +type WithCauses = { key: string; causes?: RootCause[] }; + +/** + * Search an array of containers (blocks or steps) for root causes. + */ +function searchContainersForRootCauses( + containers: WithCauses[] +): RootCause[] | null { + for (const container of containers) { + if (container.key === "root_cause_analysis" && container.causes) { + return container.causes; + } + } + return null; +} + /** - * Extract root causes from autofix state steps. + * Extract root causes from autofix state. + * Searches through both blocks and steps for root cause analysis data. * * @param state - The autofix state containing analysis steps * @returns Array of root causes, or empty array if none found */ export function extractRootCauses(state: AutofixState): RootCause[] { - if (!state.steps) { - return []; + const stateWithExtras = state as AutofixState & { + blocks?: WithCauses[]; + steps?: WithCauses[]; + }; + + if (stateWithExtras.blocks) { + const causes = searchContainersForRootCauses(stateWithExtras.blocks); + if (causes) { + return causes; + } } - for (const step of state.steps) { - if (step.key === "root_cause_analysis" && step.causes) { - return step.causes; + if (stateWithExtras.steps) { + const causes = searchContainersForRootCauses(stateWithExtras.steps); + if (causes) { + return causes; } } @@ -285,6 +312,40 @@ function findSolutionInArtifacts( return null; } +/** + * Search artifacts for a solution-keyed entry and return its reason string. + * + * When Seer completes but cannot produce a code fix, the API may return + * `{ key: "solution", data: null, reason: "..." }`. The full artifact + * fails `SolutionArtifactSchema` validation (data is required), but the + * `reason` field still carries useful context for the user. + */ +function findNoSolutionReason(artifacts: ArtifactEntry[]): string | undefined { + for (const artifact of artifacts) { + if (artifact.key === "solution" && artifact.reason) { + return artifact.reason; + } + } + return; +} + +/** + * Search containers (blocks or steps) for a no-solution reason. + */ +function searchContainersForNoSolutionReason( + containers: WithArtifacts[] +): string | undefined { + for (const container of containers) { + if (container.artifacts) { + const reason = findNoSolutionReason(container.artifacts); + if (reason) { + return reason; + } + } + } + return; +} + /** * Search an array of containers (blocks or steps) for a solution artifact. */ @@ -302,35 +363,157 @@ function searchContainersForSolution( return null; } +/** Step-level solution item returned by the Seer API */ +type StepSolutionItem = { + title: string; + code_snippet_and_analysis?: string; + relevant_code_file?: { file_path: string | null; repo_name: string }; +}; + +/** Step shape with passthrough fields for solution data */ +type StepWithSolution = { + key: string; + description?: string; + solution?: StepSolutionItem[]; +}; + +/** + * Search containers for step-level solution data. + * + * The Seer API returns solution data directly on steps with `key === "solution"` + * rather than inside the `artifacts` array. This function finds such steps and + * maps the data to the existing {@link SolutionArtifact} shape so downstream + * formatters and commands don't need changes. + */ +function searchContainersForStepLevelSolution( + containers: StepWithSolution[] +): SolutionArtifact | null { + for (const container of containers) { + if ( + container.key === "solution" && + container.solution && + container.solution.length > 0 + ) { + return { + key: "solution", + data: { + one_line_summary: container.description ?? "", + steps: container.solution.map((item) => ({ + title: item.title, + description: item.code_snippet_and_analysis ?? "", + })), + }, + }; + } + } + return null; +} + /** * Extract solution artifact from autofix state. - * Searches through both blocks and steps for the solution artifact. * - * @param state - Autofix state (may contain blocks or steps with artifacts) + * Searches through blocks and steps for solution data. The Seer API may + * return solution data in two formats: + * 1. Step-level: `step.solution[]` array with `step.description` (current API) + * 2. Artifact-level: `step.artifacts[]` with `key === "solution"` (legacy/fallback) + * + * Step-level data is checked first since it matches the current API response shape. + * + * @param state - Autofix state (may contain blocks or steps with solution data) * @returns SolutionArtifact if found, null otherwise */ export function extractSolution(state: AutofixState): SolutionArtifact | null { // Access blocks and steps from passthrough fields + const stateWithExtras = state as AutofixState & { + blocks?: (WithArtifacts & StepWithSolution)[]; + steps?: (WithArtifacts & StepWithSolution)[]; + }; + + // Search blocks first (explorer mode / newer API) + if (stateWithExtras.blocks) { + const stepLevel = searchContainersForStepLevelSolution( + stateWithExtras.blocks + ); + if (stepLevel) { + return stepLevel; + } + const artifactLevel = searchContainersForSolution(stateWithExtras.blocks); + if (artifactLevel) { + return artifactLevel; + } + } + + // Search steps (regular autofix API) + if (stateWithExtras.steps) { + const stepLevel = searchContainersForStepLevelSolution( + stateWithExtras.steps + ); + if (stepLevel) { + return stepLevel; + } + const artifactLevel = searchContainersForSolution(stateWithExtras.steps); + if (artifactLevel) { + return artifactLevel; + } + } + + return null; +} + +/** + * Extract the reason why no solution was produced. + * + * When Seer completes analysis but cannot produce a code fix, the API + * returns a solution artifact with `data: null` and a `reason` string. + * This function searches blocks and steps for that reason. + * + * @param state - Autofix state (may contain blocks or steps with artifacts) + * @returns Reason string if found, undefined otherwise + */ +export function extractNoSolutionReason( + state: AutofixState +): string | undefined { const stateWithExtras = state as AutofixState & { blocks?: WithArtifacts[]; steps?: WithArtifacts[]; }; - // Search in blocks first (explorer mode / newer API) if (stateWithExtras.blocks) { - const solution = searchContainersForSolution(stateWithExtras.blocks); - if (solution) { - return solution; + const reason = searchContainersForNoSolutionReason(stateWithExtras.blocks); + if (reason) { + return reason; } } - // Search in steps (regular autofix API) if (stateWithExtras.steps) { - const solution = searchContainersForSolution(stateWithExtras.steps); - if (solution) { - return solution; + const reason = searchContainersForNoSolutionReason(stateWithExtras.steps); + if (reason) { + return reason; } } - return null; + return; +} + +/** + * Extract file paths examined during root cause analysis. + * + * Collects file paths from reproduction steps across all root causes. + * + * @param causes - Array of root causes from the autofix state + * @returns Deduplicated array of file paths, or empty array if none found + */ +export function extractExaminedFiles(causes: RootCause[]): string[] { + const files = new Set(); + for (const cause of causes) { + if (cause.root_cause_reproduction) { + for (const step of cause.root_cause_reproduction) { + const path = step.relevant_code_file?.file_path; + if (path && path !== "N/A") { + files.add(path); + } + } + } + } + return [...files]; } diff --git a/test/lib/api-client.seer.test.ts b/test/lib/api-client.seer.test.ts index 8efcb2a0d..b86b73772 100644 --- a/test/lib/api-client.seer.test.ts +++ b/test/lib/api-client.seer.test.ts @@ -176,7 +176,7 @@ describe("getAutofixState", () => { }); describe("triggerSolutionPlanning", () => { - test("sends POST request to autofix endpoint", async () => { + test("sends POST request to autofix update endpoint", async () => { let capturedRequest: Request | undefined; let capturedBody: unknown; @@ -190,15 +190,19 @@ describe("triggerSolutionPlanning", () => { }); }; - await triggerSolutionPlanning("test-org", "123456789", 12_345); + await triggerSolutionPlanning("test-org", "123456789", 12_345, 99); expect(capturedRequest?.method).toBe("POST"); expect(capturedRequest?.url).toContain( - "/organizations/test-org/issues/123456789/autofix/" + "/organizations/test-org/issues/123456789/autofix/update/" ); expect(capturedBody).toEqual({ run_id: 12_345, - step: "solution", + payload: { + type: "select_root_cause", + cause_id: 99, + stopping_point: "solution", + }, }); }); }); diff --git a/test/types/seer.test.ts b/test/types/seer.test.ts index 0746b8587..1aee4c0d8 100644 --- a/test/types/seer.test.ts +++ b/test/types/seer.test.ts @@ -7,8 +7,12 @@ import { describe, expect, test } from "bun:test"; import { type AutofixState, + extractExaminedFiles, + extractNoSolutionReason, extractRootCauses, + extractSolution, isTerminalStatus, + type RootCause, TERMINAL_STATUSES, } from "../../src/types/seer.js"; @@ -135,4 +139,538 @@ describe("extractRootCauses", () => { expect(extractRootCauses(state)).toEqual([]); }); + + test("extracts causes from root_cause_analysis in blocks", () => { + const state = { + run_id: 456, + status: "COMPLETED", + blocks: [ + { + key: "other_block", + status: "COMPLETED", + }, + { + key: "root_cause_analysis", + status: "COMPLETED", + causes: [ + { + id: 0, + description: "Null pointer in request handler", + relevant_repos: ["org/backend"], + }, + ], + }, + ], + } as unknown as AutofixState; + + const causes = extractRootCauses(state); + expect(causes).toHaveLength(1); + expect(causes[0]?.description).toBe("Null pointer in request handler"); + }); + + test("prefers blocks over steps when both contain root causes", () => { + const state = { + run_id: 789, + status: "COMPLETED", + blocks: [ + { + key: "root_cause_analysis", + status: "COMPLETED", + causes: [{ id: 0, description: "From blocks" }], + }, + ], + steps: [ + { + id: "step-1", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause Analysis", + causes: [{ id: 0, description: "From steps" }], + }, + ], + } as unknown as AutofixState; + + const causes = extractRootCauses(state); + expect(causes).toHaveLength(1); + expect(causes[0]?.description).toBe("From blocks"); + }); +}); + +describe("extractNoSolutionReason", () => { + test("extracts reason from solution artifact in steps", () => { + const state = { + run_id: 1, + status: "COMPLETED", + steps: [ + { + id: "s1", + key: "plan", + status: "COMPLETED", + title: "Plan", + artifacts: [ + { + key: "solution", + data: null, + reason: + "Root cause is infrastructure-level, no code fix identified", + }, + ], + }, + ], + } as unknown as AutofixState; + + expect(extractNoSolutionReason(state)).toBe( + "Root cause is infrastructure-level, no code fix identified" + ); + }); + + test("extracts reason from solution artifact in blocks", () => { + const state = { + run_id: 1, + status: "COMPLETED", + blocks: [ + { + artifacts: [ + { + key: "solution", + data: null, + reason: "No actionable code change found", + }, + ], + }, + ], + } as unknown as AutofixState; + + expect(extractNoSolutionReason(state)).toBe( + "No actionable code change found" + ); + }); + + test("returns undefined when no solution artifact exists", () => { + const state: AutofixState = { + run_id: 1, + status: "COMPLETED", + steps: [ + { + id: "s1", + key: "plan", + status: "COMPLETED", + title: "Plan", + }, + ], + }; + + expect(extractNoSolutionReason(state)).toBeUndefined(); + }); + + test("returns undefined when solution artifact has no reason", () => { + const state = { + run_id: 1, + status: "COMPLETED", + steps: [ + { + id: "s1", + key: "plan", + status: "COMPLETED", + title: "Plan", + artifacts: [{ key: "solution", data: null }], + }, + ], + } as unknown as AutofixState; + + expect(extractNoSolutionReason(state)).toBeUndefined(); + }); + + test("returns undefined when no steps or blocks", () => { + const state: AutofixState = { run_id: 1, status: "COMPLETED" }; + expect(extractNoSolutionReason(state)).toBeUndefined(); + }); +}); + +describe("extractSolution", () => { + test("extracts solution from step-level data in steps", () => { + const state = { + run_id: 1, + status: "NEED_MORE_INFORMATION", + steps: [ + { + id: "s1", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause", + }, + { + id: "s2", + key: "solution", + type: "solution", + status: "COMPLETED", + title: "Solution", + description: "Fix the null pointer dereference in handler", + solution: [ + { + title: "Add null check before accessing property", + code_snippet_and_analysis: + "Check if `request.user` is defined before accessing `.id`", + relevant_code_file: { + file_path: "src/handler.ts", + repo_name: "org/repo", + }, + }, + { + title: "Add fallback error response", + code_snippet_and_analysis: + "Return 401 when user is not authenticated", + relevant_code_file: { + file_path: null, + repo_name: "org/repo", + }, + }, + ], + artifacts: [], + }, + ], + } as unknown as AutofixState; + + const result = extractSolution(state); + expect(result).not.toBeNull(); + expect(result!.key).toBe("solution"); + expect(result!.data.one_line_summary).toBe( + "Fix the null pointer dereference in handler" + ); + expect(result!.data.steps).toHaveLength(2); + expect(result!.data.steps[0]?.title).toBe( + "Add null check before accessing property" + ); + expect(result!.data.steps[0]?.description).toBe( + "Check if `request.user` is defined before accessing `.id`" + ); + expect(result!.data.steps[1]?.title).toBe("Add fallback error response"); + expect(result!.data.steps[1]?.description).toBe( + "Return 401 when user is not authenticated" + ); + }); + + test("extracts solution from step-level data in blocks", () => { + const state = { + run_id: 2, + status: "NEED_MORE_INFORMATION", + blocks: [ + { + key: "solution", + description: "Update the config parser", + solution: [ + { + title: "Handle missing fields gracefully", + code_snippet_and_analysis: + "Add default values for optional fields", + }, + ], + }, + ], + } as unknown as AutofixState; + + const result = extractSolution(state); + expect(result).not.toBeNull(); + expect(result!.data.one_line_summary).toBe("Update the config parser"); + expect(result!.data.steps).toHaveLength(1); + expect(result!.data.steps[0]?.title).toBe( + "Handle missing fields gracefully" + ); + }); + + test("falls back to artifact-level solution when no step-level data", () => { + const state = { + run_id: 3, + status: "COMPLETED", + steps: [ + { + id: "s1", + key: "plan", + status: "COMPLETED", + title: "Plan", + artifacts: [ + { + key: "solution", + data: { + one_line_summary: "Fix from artifact", + steps: [{ title: "Step A", description: "Do A" }], + }, + }, + ], + }, + ], + } as unknown as AutofixState; + + const result = extractSolution(state); + expect(result).not.toBeNull(); + expect(result!.data.one_line_summary).toBe("Fix from artifact"); + expect(result!.data.steps).toHaveLength(1); + }); + + test("prefers step-level data over artifact-level in same container list", () => { + const state = { + run_id: 4, + status: "NEED_MORE_INFORMATION", + steps: [ + { + id: "s1", + key: "plan", + status: "COMPLETED", + title: "Plan", + artifacts: [ + { + key: "solution", + data: { + one_line_summary: "From artifact", + steps: [{ title: "Artifact step", description: "..." }], + }, + }, + ], + }, + { + id: "s2", + key: "solution", + status: "COMPLETED", + title: "Solution", + description: "From step-level", + solution: [ + { + title: "Step-level fix", + code_snippet_and_analysis: "Do the fix", + }, + ], + artifacts: [], + }, + ], + } as unknown as AutofixState; + + const result = extractSolution(state); + expect(result).not.toBeNull(); + expect(result!.data.one_line_summary).toBe("From step-level"); + }); + + test("returns null when no solution data exists", () => { + const state: AutofixState = { + run_id: 5, + status: "PROCESSING", + steps: [ + { + id: "s1", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause", + }, + ], + }; + + expect(extractSolution(state)).toBeNull(); + }); + + test("returns null when no steps or blocks", () => { + const state: AutofixState = { run_id: 6, status: "PROCESSING" }; + expect(extractSolution(state)).toBeNull(); + }); + + test("handles step-level solution with empty solution array", () => { + const state = { + run_id: 7, + status: "NEED_MORE_INFORMATION", + steps: [ + { + id: "s1", + key: "solution", + status: "COMPLETED", + title: "Solution", + description: "Summary", + solution: [], + artifacts: [], + }, + ], + } as unknown as AutofixState; + + // Empty solution array should not match — no actionable steps + expect(extractSolution(state)).toBeNull(); + }); + + test("handles step-level solution without description", () => { + const state = { + run_id: 8, + status: "NEED_MORE_INFORMATION", + steps: [ + { + id: "s1", + key: "solution", + status: "COMPLETED", + title: "Solution", + solution: [ + { + title: "Quick fix", + code_snippet_and_analysis: "Apply patch", + }, + ], + artifacts: [], + }, + ], + } as unknown as AutofixState; + + const result = extractSolution(state); + expect(result).not.toBeNull(); + expect(result!.data.one_line_summary).toBe(""); + expect(result!.data.steps[0]?.title).toBe("Quick fix"); + }); + + test("handles solution item without code_snippet_and_analysis", () => { + const state = { + run_id: 9, + status: "NEED_MORE_INFORMATION", + steps: [ + { + id: "s1", + key: "solution", + status: "COMPLETED", + title: "Solution", + description: "Summary", + solution: [{ title: "Title only" }], + artifacts: [], + }, + ], + } as unknown as AutofixState; + + const result = extractSolution(state); + expect(result).not.toBeNull(); + expect(result!.data.steps[0]?.description).toBe(""); + }); +}); + +describe("extractExaminedFiles", () => { + test("extracts file paths from reproduction steps", () => { + const causes: RootCause[] = [ + { + id: 0, + description: "HTTP/1.1 overhead", + root_cause_reproduction: [ + { + title: "Step 1", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "src/mdx.ts", + repo_name: "org/repo", + }, + }, + { + title: "Step 2", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "app/layout.tsx", + repo_name: "org/repo", + }, + }, + ], + }, + ]; + + const files = extractExaminedFiles(causes); + expect(files).toEqual(["src/mdx.ts", "app/layout.tsx"]); + }); + + test("deduplicates file paths", () => { + const causes: RootCause[] = [ + { + id: 0, + description: "Bug", + root_cause_reproduction: [ + { + title: "Step 1", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "src/index.ts", + repo_name: "org/repo", + }, + }, + { + title: "Step 2", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "src/index.ts", + repo_name: "org/repo", + }, + }, + ], + }, + ]; + + expect(extractExaminedFiles(causes)).toEqual(["src/index.ts"]); + }); + + test("returns empty array when no reproduction steps", () => { + const causes: RootCause[] = [{ id: 0, description: "Bug" }]; + + expect(extractExaminedFiles(causes)).toEqual([]); + }); + + test("returns empty array for empty causes", () => { + expect(extractExaminedFiles([])).toEqual([]); + }); + + test("skips steps without relevant_code_file", () => { + const causes: RootCause[] = [ + { + id: 0, + description: "Bug", + root_cause_reproduction: [ + { + title: "Step 1", + code_snippet_and_analysis: "...", + }, + { + title: "Step 2", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "src/app.ts", + repo_name: "org/repo", + }, + }, + ], + }, + ]; + + expect(extractExaminedFiles(causes)).toEqual(["src/app.ts"]); + }); + + test("filters out N/A sentinel values from file paths", () => { + const causes: RootCause[] = [ + { + id: 0, + description: "Infrastructure issue", + root_cause_reproduction: [ + { + title: "Code step", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "app/layout.tsx", + repo_name: "getsentry/sentry-docs", + }, + }, + { + title: "External system step", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "N/A", + repo_name: "N/A", + }, + }, + { + title: "Another external step", + code_snippet_and_analysis: "...", + relevant_code_file: { + file_path: "N/A", + repo_name: "N/A", + }, + }, + ], + }, + ]; + + expect(extractExaminedFiles(causes)).toEqual(["app/layout.tsx"]); + }); });