From 03ffd6bcb0e336b5f5b63692acac4bc1de5970f4 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 22 Jan 2026 15:47:14 +0530 Subject: [PATCH 01/19] feat: implmented the base api for seer --- bun.lock | 7 +- src/commands/issue/explain.ts | 256 ++++++++++++++++++++++++ src/commands/issue/fix.ts | 341 ++++++++++++++++++++++++++++++++ src/commands/issue/index.ts | 12 +- src/lib/api-client.ts | 93 +++++++++ src/lib/formatters/autofix.ts | 353 ++++++++++++++++++++++++++++++++++ src/lib/formatters/index.ts | 1 + src/types/autofix.ts | 318 ++++++++++++++++++++++++++++++ src/types/index.ts | 25 +++ 9 files changed, 1401 insertions(+), 5 deletions(-) create mode 100644 src/commands/issue/explain.ts create mode 100644 src/commands/issue/fix.ts create mode 100644 src/lib/formatters/autofix.ts create mode 100644 src/types/autofix.ts diff --git a/bun.lock b/bun.lock index c861bd74c..47a754587 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "sentry", @@ -9,7 +8,7 @@ "@stricli/auto-complete": "^1.2.4", "@stricli/core": "^1.2.4", "@types/bun": "latest", - "@types/node": "^20", + "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", "chalk": "^5.6.2", "esbuild": "^0.25.0", @@ -109,7 +108,7 @@ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], - "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="], @@ -167,6 +166,8 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "bun-types/@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "ultracite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], } } diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts new file mode 100644 index 000000000..2978dea88 --- /dev/null +++ b/src/commands/issue/explain.ts @@ -0,0 +1,256 @@ +/** + * sentry issue explain + * + * Trigger Seer root cause analysis for a Sentry issue. + */ + +import { buildCommand } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { + getAutofixState, + getIssueByShortId, + isShortId, + triggerAutofix, +} from "../../lib/api-client.js"; +import { ApiError, ContextError } from "../../lib/errors.js"; +import { + formatAutofixError, + formatProgressLine, + formatRootCauseList, + getProgressMessage, +} from "../../lib/formatters/autofix.js"; +import { writeJson } from "../../lib/formatters/index.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { extractRootCauses, isTerminalStatus } from "../../types/autofix.js"; +import type { Writer } from "../../types/index.js"; + +/** Polling interval in milliseconds */ +const POLL_INTERVAL_MS = 3000; + +/** Maximum time to wait for completion in milliseconds (10 minutes) */ +const TIMEOUT_MS = 600_000; + +type ExplainFlags = { + readonly org?: string; + readonly event?: string; + readonly instruction?: string; + readonly json: boolean; +}; + +/** + * Resolve the numeric issue ID from either a numeric ID or short ID. + * + * @param issueId - User-provided issue ID (numeric or short) + * @param org - Optional org slug for short ID resolution + * @param cwd - Current working directory + * @returns Numeric issue ID + */ +async function resolveIssueId( + issueId: string, + org: string | undefined, + cwd: string +): Promise { + if (!isShortId(issueId)) { + return issueId; + } + + // Short ID requires organization context + const resolved = await resolveOrg({ org, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + `sentry issue explain ${issueId} --org ` + ); + } + + const issue = await getIssueByShortId(resolved.org, issueId); + return issue.id; +} + +/** + * Poll for autofix completion with progress display. + * + * @param issueId - Numeric issue ID + * @param stdout - Output writer + * @param stderr - Error writer + * @param json - Whether to suppress progress output (for JSON mode) + */ +async function pollUntilComplete( + issueId: string, + _stdout: Writer, + stderr: Writer, + json: boolean +): Promise> { + const startTime = Date.now(); + let tick = 0; + let lastMessage = ""; + + while (Date.now() - startTime < TIMEOUT_MS) { + const state = await getAutofixState(issueId); + + if (!state) { + // No autofix state yet, keep waiting + await Bun.sleep(POLL_INTERVAL_MS); + continue; + } + + // Show progress if not in JSON mode + if (!json) { + const message = getProgressMessage(state); + if (message !== lastMessage) { + // Clear current line and write new progress + stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); + lastMessage = message; + } else { + // Update spinner + stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); + } + tick += 1; + } + + // Check if we're done + if (isTerminalStatus(state.status)) { + if (!json) { + stderr.write("\n"); + } + return state; + } + + // Also check for WAITING_FOR_USER_RESPONSE which means root cause is ready + if (state.status === "WAITING_FOR_USER_RESPONSE") { + if (!json) { + stderr.write("\n"); + } + return state; + } + + await Bun.sleep(POLL_INTERVAL_MS); + } + + throw new Error( + "Analysis timed out after 10 minutes. Check the issue in Sentry web UI." + ); +} + +export const explainCommand = buildCommand({ + docs: { + brief: "Analyze an issue using Seer AI", + fullDescription: + "Trigger Seer's AI-powered root cause analysis for a Sentry issue.\n\n" + + "This command starts an analysis that identifies the root cause of the issue " + + "and shows reproduction steps. Once complete, you can use 'sentry issue fix' " + + "to create a pull request with the fix.\n\n" + + "Examples:\n" + + " sentry issue explain 123456789\n" + + " sentry issue explain MYPROJECT-ABC --org my-org\n" + + " sentry issue explain 123456789 --instruction 'Focus on the database query'", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Issue ID or short ID (e.g., MYPROJECT-ABC or 123456789)", + parse: String, + }, + ], + }, + flags: { + org: { + kind: "parsed", + parse: String, + brief: + "Organization slug (required for short IDs if not auto-detected)", + optional: true, + }, + event: { + kind: "parsed", + parse: String, + brief: + "Specific event ID to analyze (uses recommended event if not provided)", + optional: true, + }, + instruction: { + kind: "parsed", + parse: String, + brief: "Custom instruction to guide the analysis", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + }, + }, + async func( + this: SentryContext, + flags: ExplainFlags, + issueId: string + ): Promise { + const { stdout, stderr, cwd } = this; + + try { + // Resolve the numeric issue ID + const numericId = await resolveIssueId(issueId, flags.org, cwd); + + if (!flags.json) { + stderr.write(`Analyzing issue ${issueId}...\n`); + } + + // Trigger the autofix with root_cause stopping point + await triggerAutofix(numericId, { + stoppingPoint: "root_cause", + eventId: flags.event, + instruction: flags.instruction, + }); + + // Poll until complete + const finalState = await pollUntilComplete( + numericId, + stdout, + stderr, + flags.json + ); + + if (!finalState) { + throw new Error("No autofix state returned."); + } + + // Handle errors + if (finalState.status === "ERROR") { + throw new Error( + "Root cause analysis failed. Check the Sentry web UI for details." + ); + } + + if (finalState.status === "CANCELLED") { + throw new Error("Root cause analysis was cancelled."); + } + + // Extract root causes + const rootCauses = extractRootCauses(finalState); + + // Output results + if (flags.json) { + writeJson(stdout, { + run_id: finalState.run_id, + status: finalState.status, + causes: rootCauses, + }); + return; + } + + // Human-readable output + const lines = formatRootCauseList(rootCauses, issueId); + stdout.write(`${lines.join("\n")}\n`); + } catch (error) { + // Handle API errors with friendly messages + if (error instanceof ApiError) { + const message = formatAutofixError(error.status, error.detail); + throw new Error(message); + } + throw error; + } + }, +}); diff --git a/src/commands/issue/fix.ts b/src/commands/issue/fix.ts new file mode 100644 index 000000000..1fb1aacd3 --- /dev/null +++ b/src/commands/issue/fix.ts @@ -0,0 +1,341 @@ +/** + * sentry issue fix + * + * Create a pull request with a fix for a Sentry issue using Seer AI. + * Requires that 'sentry issue explain' has been run first. + */ + +import { buildCommand, numberParser } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { + getAutofixState, + getIssueByShortId, + isShortId, + updateAutofix, +} from "../../lib/api-client.js"; +import { ApiError, ContextError, ValidationError } from "../../lib/errors.js"; +import { + formatAutofixError, + formatPrNotFound, + formatProgressLine, + formatPrResult, + getProgressMessage, +} from "../../lib/formatters/autofix.js"; +import { muted } from "../../lib/formatters/colors.js"; +import { writeJson } from "../../lib/formatters/index.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { + type AutofixState, + extractPrUrl, + extractRootCauses, + isTerminalStatus, + type RootCause, +} from "../../types/autofix.js"; +import type { Writer } from "../../types/index.js"; + +/** Polling interval in milliseconds */ +const POLL_INTERVAL_MS = 3000; + +/** Maximum time to wait for PR creation in milliseconds (10 minutes) */ +const TIMEOUT_MS = 600_000; + +type FixFlags = { + readonly org?: string; + readonly cause?: number; + readonly json: boolean; +}; + +/** + * Resolve the numeric issue ID from either a numeric ID or short ID. + */ +async function resolveIssueId( + issueId: string, + org: string | undefined, + cwd: string +): Promise { + if (!isShortId(issueId)) { + return issueId; + } + + const resolved = await resolveOrg({ org, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + `sentry issue fix ${issueId} --org ` + ); + } + + const issue = await getIssueByShortId(resolved.org, issueId); + return issue.id; +} + +/** + * Validate that an autofix run exists and has completed root cause analysis. + * + * @param state - Current autofix state + * @param issueId - Issue ID for error messages + * @returns The validated state and root causes + */ +function validateAutofixState( + state: AutofixState | null, + issueId: string +): { state: AutofixState; causes: RootCause[] } { + if (!state) { + throw new ValidationError( + `No root cause analysis found for issue ${issueId}.\n` + + `Run 'sentry issue explain ${issueId}' first.` + ); + } + + // Check if the autofix is in a state where we can continue + const validStatuses = ["COMPLETED", "WAITING_FOR_USER_RESPONSE"]; + if (!validStatuses.includes(state.status)) { + if (state.status === "PROCESSING") { + throw new ValidationError( + "Root cause analysis is still in progress. Please wait for it to complete." + ); + } + if (state.status === "ERROR") { + throw new ValidationError( + "Root cause analysis failed. Check the Sentry web UI for details." + ); + } + throw new ValidationError( + `Cannot create fix: autofix is in '${state.status}' state.` + ); + } + + const causes = extractRootCauses(state); + if (causes.length === 0) { + throw new ValidationError( + "No root causes identified. Cannot create a fix without a root cause." + ); + } + + return { state, causes }; +} + +/** + * Validate the cause selection. + */ +function validateCauseSelection( + causes: RootCause[], + selectedCause: number | undefined, + issueId: string +): number { + // If only one cause and none specified, use it + if (causes.length === 1 && selectedCause === undefined) { + return 0; + } + + // If multiple causes and none specified, error with list + if (causes.length > 1 && selectedCause === undefined) { + const lines = [ + "Multiple root causes found. Please specify one with --cause :", + "", + ]; + for (let i = 0; i < causes.length; i++) { + const cause = causes[i]; + if (cause) { + lines.push(` ${i}: ${cause.description.slice(0, 60)}...`); + } + } + lines.push(""); + lines.push(`Example: sentry issue fix ${issueId} --cause 0`); + throw new ValidationError(lines.join("\n")); + } + + const causeId = selectedCause ?? 0; + + // Validate the cause ID is in range + if (causeId < 0 || causeId >= causes.length) { + throw new ValidationError( + `Invalid cause ID: ${causeId}. Valid range is 0-${causes.length - 1}.` + ); + } + + return causeId; +} + +/** + * Poll for PR creation completion. + */ +async function pollUntilPrCreated( + issueId: string, + _stdout: Writer, + stderr: Writer, + json: boolean +): Promise { + const startTime = Date.now(); + let tick = 0; + let lastMessage = ""; + + while (Date.now() - startTime < TIMEOUT_MS) { + const state = await getAutofixState(issueId); + + if (!state) { + await Bun.sleep(POLL_INTERVAL_MS); + continue; + } + + // Show progress if not in JSON mode + if (!json) { + const message = getProgressMessage(state); + if (message !== lastMessage) { + stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); + lastMessage = message; + } else { + stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); + } + tick += 1; + } + + // Check if we're done + if (isTerminalStatus(state.status)) { + if (!json) { + stderr.write("\n"); + } + return state; + } + + await Bun.sleep(POLL_INTERVAL_MS); + } + + throw new Error( + "PR creation timed out after 10 minutes. Check the issue in Sentry web UI." + ); +} + +export const fixCommand = buildCommand({ + docs: { + brief: "Create a PR with a fix using Seer AI", + fullDescription: + "Create a pull request with a fix for a Sentry issue using Seer AI.\n\n" + + "This command requires that 'sentry issue explain' has been run first " + + "to identify the root cause. It will then generate code changes and " + + "create a pull request with the fix.\n\n" + + "If multiple root causes were identified, use --cause to specify which one.\n\n" + + "Prerequisites:\n" + + " - GitHub integration configured for your organization\n" + + " - Code mappings set up for your project\n" + + " - Repository write access for the integration\n\n" + + "Examples:\n" + + " sentry issue fix 123456789 --cause 0\n" + + " sentry issue fix MYPROJECT-ABC --org my-org --cause 1", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Issue ID or short ID (e.g., MYPROJECT-ABC or 123456789)", + parse: String, + }, + ], + }, + flags: { + org: { + kind: "parsed", + parse: String, + brief: + "Organization slug (required for short IDs if not auto-detected)", + optional: true, + }, + cause: { + kind: "parsed", + parse: numberParser, + brief: "Root cause ID to fix (required if multiple causes exist)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + }, + }, + async func( + this: SentryContext, + flags: FixFlags, + issueId: string + ): Promise { + const { stdout, stderr, cwd } = this; + + try { + // Resolve the numeric issue ID + const numericId = await resolveIssueId(issueId, flags.org, cwd); + + // Get current autofix state + const currentState = await getAutofixState(numericId); + + // Validate we have a completed root cause analysis + const { state, causes } = validateAutofixState(currentState, issueId); + + // Validate cause selection + const causeId = validateCauseSelection(causes, flags.cause, issueId); + const selectedCause = causes[causeId]; + + if (!flags.json) { + stderr.write(`Creating fix for cause #${causeId}...\n`); + if (selectedCause) { + stderr.write(`${muted(`"${selectedCause.description}"`)}\n\n`); + } + } + + // Update autofix to continue to PR creation + await updateAutofix(numericId, state.run_id, { + type: "select_root_cause", + cause_id: causeId, + stopping_point: "open_pr", + }); + + // Poll until PR is created + const finalState = await pollUntilPrCreated( + numericId, + stdout, + stderr, + flags.json + ); + + // Handle errors + if (finalState.status === "ERROR") { + throw new Error( + "Fix creation failed. Check the Sentry web UI for details." + ); + } + + if (finalState.status === "CANCELLED") { + throw new Error("Fix creation was cancelled."); + } + + // Try to extract PR URL + const prUrl = extractPrUrl(finalState); + + // Output results + if (flags.json) { + writeJson(stdout, { + run_id: finalState.run_id, + status: finalState.status, + pr_url: prUrl ?? null, + }); + return; + } + + // Human-readable output + if (prUrl) { + const lines = formatPrResult(prUrl); + stdout.write(`${lines.join("\n")}\n`); + } else { + const lines = formatPrNotFound(); + stdout.write(`${lines.join("\n")}\n`); + } + } catch (error) { + // Handle API errors with friendly messages + if (error instanceof ApiError) { + const message = formatAutofixError(error.status, error.detail); + throw new Error(message); + } + throw error; + } + }, +}); diff --git a/src/commands/issue/index.ts b/src/commands/issue/index.ts index 45168e840..ac1e58c35 100644 --- a/src/commands/issue/index.ts +++ b/src/commands/issue/index.ts @@ -1,4 +1,6 @@ import { buildRouteMap } from "@stricli/core"; +import { explainCommand } from "./explain.js"; +import { fixCommand } from "./fix.js"; import { getCommand } from "./get.js"; import { listCommand } from "./list.js"; @@ -6,12 +8,18 @@ export const issueRoute = buildRouteMap({ routes: { list: listCommand, get: getCommand, + explain: explainCommand, + fix: fixCommand, }, docs: { brief: "Manage Sentry issues", fullDescription: - "View and manage issues from your Sentry projects. " + - "Use 'sentry issue list' to list issues and 'sentry issue get ' to view issue details.", + "View and manage issues from your Sentry projects.\n\n" + + "Commands:\n" + + " list List issues in a project\n" + + " get Get details of a specific issue\n" + + " explain Analyze an issue using Seer AI\n" + + " fix Create a PR with a fix using Seer AI", hideRoute: {}, }, }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 63ac28cb5..9fa3d6547 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -7,6 +7,15 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; +import { + type AutofixResponse, + AutofixResponseSchema, + type AutofixState, + type AutofixTriggerResponse, + AutofixTriggerResponseSchema, + type AutofixUpdatePayload, + type StoppingPoint, +} from "../types/autofix.js"; import { type SentryEvent, SentryEventSchema, @@ -401,3 +410,87 @@ export function updateIssueStatus( schema: SentryIssueSchema, }); } + +// ───────────────────────────────────────────────────────────────────────────── +// Autofix (Seer) API Methods +// ───────────────────────────────────────────────────────────────────────────── + +type TriggerAutofixOptions = { + /** Where to stop the autofix process */ + stoppingPoint?: StoppingPoint; + /** Specific event ID to analyze (uses recommended event if not provided) */ + eventId?: string; + /** Custom instruction to guide the autofix process */ + instruction?: string; +}; + +/** + * Trigger an autofix run for an issue. + * + * @param issueId - The numeric Sentry issue ID + * @param options - Options for the autofix run + * @returns The run_id for polling status + * @throws {ApiError} On API errors (402 = no budget, 403 = not enabled) + */ +export function triggerAutofix( + issueId: string, + options: TriggerAutofixOptions = {} +): Promise { + const body: Record = {}; + + if (options.stoppingPoint) { + body.stoppingPoint = options.stoppingPoint; + } + if (options.eventId) { + body.eventId = options.eventId; + } + if (options.instruction) { + body.instruction = options.instruction; + } + + return apiRequest(`/issues/${issueId}/autofix/`, { + method: "POST", + body, + schema: AutofixTriggerResponseSchema, + }); +} + +/** + * Get the current autofix state for an issue. + * + * @param issueId - The numeric Sentry issue ID + * @returns The autofix state, or null if no autofix has been run + */ +export async function getAutofixState( + issueId: string +): Promise { + const response = await apiRequest( + `/issues/${issueId}/autofix/`, + { + schema: AutofixResponseSchema, + } + ); + return response.autofix; +} + +/** + * Update an autofix run (e.g., select root cause, continue to PR). + * + * @param issueId - The numeric Sentry issue ID + * @param runId - The autofix run ID + * @param payload - The update payload (select_root_cause, select_solution, create_pr) + * @returns The response from the API + */ +export function updateAutofix( + issueId: string, + runId: number, + payload: AutofixUpdatePayload +): Promise { + return apiRequest(`/issues/${issueId}/autofix/update/`, { + method: "POST", + body: { + run_id: runId, + payload, + }, + }); +} diff --git a/src/lib/formatters/autofix.ts b/src/lib/formatters/autofix.ts new file mode 100644 index 000000000..3dc8d0815 --- /dev/null +++ b/src/lib/formatters/autofix.ts @@ -0,0 +1,353 @@ +/** + * Autofix Output Formatters + * + * Formatting utilities for Seer Autofix command output. + */ + +import type { + AutofixState, + AutofixStep, + RootCause, +} from "../../types/autofix.js"; +import { cyan, green, muted, yellow } from "./colors.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Spinner Frames +// ───────────────────────────────────────────────────────────────────────────── + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/** + * Get a spinner frame for the given tick count. + */ +export function getSpinnerFrame(tick: number): string { + const index = tick % SPINNER_FRAMES.length; + // biome-ignore lint/style/noNonNullAssertion: index is always valid due to modulo + return SPINNER_FRAMES[index]!; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Progress Formatting +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format a progress message with spinner. + * + * @param message - Progress message to display + * @param tick - Spinner tick count + * @returns Formatted progress line + */ +export function formatProgressLine(message: string, tick: number): string { + const spinner = cyan(getSpinnerFrame(tick)); + return `${spinner} ${message}`; +} + +/** + * Extract the latest progress message from autofix state. + * + * @param state - Current autofix state + * @returns Latest progress message or default + */ +export function getProgressMessage(state: AutofixState): string { + if (!state.steps) { + return "Processing..."; + } + + // Find the most recent progress message + for (let i = state.steps.length - 1; i >= 0; i--) { + const step = state.steps[i]; + if (step?.progress && step.progress.length > 0) { + const lastProgress = step.progress.at(-1); + if (lastProgress?.message) { + return lastProgress.message; + } + } + } + + // Fallback based on status + switch (state.status) { + case "PROCESSING": + return "Analyzing issue..."; + case "COMPLETED": + return "Analysis complete"; + case "ERROR": + return "Analysis failed"; + default: + return "Processing..."; + } +} + +/** + * Get the current step title from autofix state. + */ +export function getCurrentStepTitle(state: AutofixState): string | undefined { + if (!state.steps) { + return; + } + + // Find the step that's currently processing + for (const step of state.steps) { + if (step.status === "PROCESSING" || step.status === "PENDING") { + return step.title; + } + } + + return; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Root Cause Formatting +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format a single reproduction step. + */ +function formatReproductionStep( + step: { title: string; code_snippet_and_analysis: string }, + index: number +): string[] { + const lines: string[] = []; + lines.push(` ${index + 1}. ${step.title}`); + + // Indent the analysis + const analysisLines = step.code_snippet_and_analysis + .split("\n") + .map((line) => ` ${line}`); + lines.push(...analysisLines); + + return lines; +} + +/** + * Format a single root cause for display. + * + * @param cause - Root cause to format + * @param index - Index for display (used as cause ID) + * @returns Array of formatted lines + */ +export function formatRootCause(cause: RootCause, index: number): string[] { + const lines: string[] = []; + + // Cause header + lines.push(`${yellow(`Cause #${index}`)}: ${cause.description}`); + + // Relevant repositories + if (cause.relevant_repos && cause.relevant_repos.length > 0) { + lines.push(` ${muted("Repository:")} ${cause.relevant_repos.join(", ")}`); + } + + // Reproduction steps + if ( + cause.root_cause_reproduction && + cause.root_cause_reproduction.length > 0 + ) { + lines.push(""); + lines.push(` ${muted("Reproduction:")}`); + for (let i = 0; i < cause.root_cause_reproduction.length; i++) { + const step = cause.root_cause_reproduction[i]; + if (step) { + lines.push(...formatReproductionStep(step, i)); + } + } + } + + return lines; +} + +/** + * Format the root cause analysis header. + */ +export function formatRootCauseHeader(): string[] { + return ["", green("Root Cause Analysis Complete"), muted("═".repeat(30)), ""]; +} + +/** + * Format all root causes with a footer hint. + * + * @param causes - Array of root causes + * @param issueId - Issue ID for the hint + * @returns Array of formatted lines + */ +export function formatRootCauseList( + causes: RootCause[], + issueId: string +): string[] { + const lines: string[] = []; + + lines.push(...formatRootCauseHeader()); + + if (causes.length === 0) { + lines.push(muted("No root causes identified.")); + return lines; + } + + for (let i = 0; i < causes.length; i++) { + const cause = causes[i]; + if (cause) { + if (i > 0) { + lines.push(""); + } + lines.push(...formatRootCause(cause, i)); + } + } + + // Add hint for next steps + lines.push(""); + if (causes.length === 1) { + lines.push( + muted(`To create a fix, run: sentry issue fix ${issueId} --cause 0`) + ); + } else { + lines.push( + muted(`To create a fix, run: sentry issue fix ${issueId} --cause `) + ); + lines.push(muted(` where is 0-${causes.length - 1}`)); + } + + return lines; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fix / PR Result Formatting +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format the PR creation result. + * + * @param prUrl - URL of the created PR + * @returns Array of formatted lines + */ +export function formatPrResult(prUrl: string): string[] { + return [ + "", + green("Pull Request Created"), + muted("═".repeat(21)), + "", + `URL: ${cyan(prUrl)}`, + ]; +} + +/** + * Format an error when no PR URL could be found. + */ +export function formatPrNotFound(): string[] { + return [ + "", + yellow("Fix process completed but no PR URL found."), + muted("Check the Sentry web UI for the autofix results."), + ]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Status Formatting +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format autofix status for display. + * + * @param status - Autofix status string + * @returns Colored status string + */ +export function formatAutofixStatus(status: string): string { + switch (status) { + case "COMPLETED": + return green("Completed"); + case "PROCESSING": + return cyan("Processing"); + case "ERROR": + return yellow("Error"); + case "CANCELLED": + return muted("Cancelled"); + case "WAITING_FOR_USER_RESPONSE": + return yellow("Waiting for input"); + default: + return status; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Step Formatting (for verbose/debug output) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format an autofix step for display. + * + * @param step - The step to format + * @returns Array of formatted lines + */ +export function formatAutofixStep(step: AutofixStep): string[] { + const lines: string[] = []; + + let statusIcon: string; + if (step.status === "COMPLETED") { + statusIcon = green("✓"); + } else if (step.status === "PROCESSING") { + statusIcon = cyan("●"); + } else { + statusIcon = muted("○"); + } + + lines.push(`${statusIcon} ${step.title}`); + + // Show progress messages if any + if (step.progress && step.progress.length > 0) { + const lastProgress = step.progress.at(-1); + if (lastProgress) { + lines.push(` ${muted(lastProgress.message)}`); + } + } + + return lines; +} + +/** + * Format all steps summary. + * + * @param state - Autofix state + * @returns Array of formatted lines + */ +export function formatStepsSummary(state: AutofixState): string[] { + if (!state.steps || state.steps.length === 0) { + return []; + } + + const lines: string[] = []; + lines.push(""); + lines.push(muted("Steps:")); + + for (const step of state.steps) { + lines.push(...formatAutofixStep(step).map((line) => ` ${line}`)); + } + + return lines; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Error Messages +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format an error message for common autofix errors. + * + * @param status - HTTP status code + * @param detail - Error detail from API + * @returns User-friendly error message + */ +export function formatAutofixError(status: number, detail?: string): string { + switch (status) { + case 402: + return "No budget for Seer Autofix. Check your billing plan."; + case 403: + if (detail?.includes("not enabled")) { + return "Seer Autofix is not enabled for this organization."; + } + if (detail?.includes("AI features")) { + return "AI features are disabled for this organization."; + } + return detail ?? "Seer Autofix is not available."; + case 404: + return "Issue not found."; + default: + return detail ?? "An error occurred with the autofix request."; + } +} diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index 18b088a9b..b07533b1c 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -5,6 +5,7 @@ * Re-exports all formatting utilities for CLI output. */ +export * from "./autofix.js"; export * from "./colors.js"; export * from "./human.js"; export * from "./json.js"; diff --git a/src/types/autofix.ts b/src/types/autofix.ts new file mode 100644 index 000000000..5e10d1bd9 --- /dev/null +++ b/src/types/autofix.ts @@ -0,0 +1,318 @@ +/** + * Autofix API Types + * + * Zod schemas and TypeScript types for Sentry's Seer Autofix API. + */ + +import { z } from "zod"; + +// ───────────────────────────────────────────────────────────────────────────── +// Status Constants +// ───────────────────────────────────────────────────────────────────────────── + +/** Possible autofix run statuses */ +export const AUTOFIX_STATUSES = [ + "PROCESSING", + "COMPLETED", + "ERROR", + "CANCELLED", + "NEED_MORE_INFORMATION", + "WAITING_FOR_USER_RESPONSE", +] as const; + +export type AutofixStatus = (typeof AUTOFIX_STATUSES)[number]; + +/** Terminal statuses that indicate the run has finished */ +export const TERMINAL_STATUSES: AutofixStatus[] = [ + "COMPLETED", + "ERROR", + "CANCELLED", +]; + +/** Stopping point values for autofix runs */ +export const STOPPING_POINTS = [ + "root_cause", + "solution", + "code_changes", + "open_pr", +] as const; + +export type StoppingPoint = (typeof STOPPING_POINTS)[number]; + +// ───────────────────────────────────────────────────────────────────────────── +// Trigger Response +// ───────────────────────────────────────────────────────────────────────────── + +export const AutofixTriggerResponseSchema = z.object({ + run_id: z.number(), +}); + +export type AutofixTriggerResponse = z.infer< + typeof AutofixTriggerResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// Progress Message +// ───────────────────────────────────────────────────────────────────────────── + +export const ProgressMessageSchema = z.object({ + message: z.string(), + timestamp: z.string(), + type: z.string().optional(), +}); + +export type ProgressMessage = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Relevant Code File +// ───────────────────────────────────────────────────────────────────────────── + +export const RelevantCodeFileSchema = z.object({ + file_path: z.string(), + repo_name: z.string(), +}); + +export type RelevantCodeFile = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Reproduction Step +// ───────────────────────────────────────────────────────────────────────────── + +export const ReproductionStepSchema = z.object({ + title: z.string(), + code_snippet_and_analysis: z.string(), + is_most_important_event: z.boolean().optional(), + relevant_code_file: RelevantCodeFileSchema.optional(), + timeline_item_type: z.string().optional(), +}); + +export type ReproductionStep = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Root Cause +// ───────────────────────────────────────────────────────────────────────────── + +export const RootCauseSchema = z.object({ + id: z.number(), + description: z.string(), + relevant_repos: z.array(z.string()).optional(), + reproduction_urls: z.array(z.string()).optional(), + root_cause_reproduction: z.array(ReproductionStepSchema).optional(), +}); + +export type RootCause = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Root Cause Selection +// ───────────────────────────────────────────────────────────────────────────── + +export const RootCauseSelectionSchema = z.object({ + cause_id: z.number(), + instruction: z.string().nullable().optional(), +}); + +export type RootCauseSelection = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Autofix Step +// ───────────────────────────────────────────────────────────────────────────── + +export const AutofixStepSchema = z.object({ + id: z.string(), + key: z.string(), + status: z.string(), + title: z.string(), + progress: z.array(ProgressMessageSchema).optional(), + causes: z.array(RootCauseSchema).optional(), + selection: RootCauseSelectionSchema.optional(), +}); + +export type AutofixStep = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Repository Info +// ───────────────────────────────────────────────────────────────────────────── + +export const RepositoryInfoSchema = z.object({ + integration_id: z.number().optional(), + url: z.string().optional(), + external_id: z.string(), + name: z.string(), + provider: z.string().optional(), + default_branch: z.string().optional(), + is_readable: z.boolean().optional(), + is_writeable: z.boolean().optional(), +}); + +export type RepositoryInfo = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Codebase Info +// ───────────────────────────────────────────────────────────────────────────── + +export const CodebaseInfoSchema = z.object({ + repo_external_id: z.string(), + file_changes: z.array(z.unknown()).optional(), + is_readable: z.boolean().optional(), + is_writeable: z.boolean().optional(), +}); + +export type CodebaseInfo = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// PR Info (from completed fix) +// ───────────────────────────────────────────────────────────────────────────── + +export const PullRequestInfoSchema = z.object({ + pr_number: z.number().optional(), + pr_url: z.string().optional(), + repo_name: z.string().optional(), +}); + +export type PullRequestInfo = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Autofix State +// ───────────────────────────────────────────────────────────────────────────── + +export const AutofixStateSchema = z.object({ + run_id: z.number(), + status: z.string(), + updated_at: z.string().optional(), + request: z + .object({ + organization_id: z.number().optional(), + project_id: z.number().optional(), + repos: z.array(z.unknown()).optional(), + }) + .optional(), + codebases: z.record(z.string(), CodebaseInfoSchema).optional(), + steps: z.array(AutofixStepSchema).optional(), + repositories: z.array(RepositoryInfoSchema).optional(), + coding_agents: z.record(z.string(), z.unknown()).optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), +}); + +export type AutofixState = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Autofix Response (GET /issues/{id}/autofix/) +// ───────────────────────────────────────────────────────────────────────────── + +export const AutofixResponseSchema = z.object({ + autofix: AutofixStateSchema.nullable(), +}); + +export type AutofixResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Update Payloads +// ───────────────────────────────────────────────────────────────────────────── + +export const SelectRootCausePayloadSchema = z.object({ + type: z.literal("select_root_cause"), + cause_id: z.number(), + stopping_point: z.enum(["solution", "code_changes", "open_pr"]).optional(), +}); + +export type SelectRootCausePayload = z.infer< + typeof SelectRootCausePayloadSchema +>; + +export const SelectSolutionPayloadSchema = z.object({ + type: z.literal("select_solution"), +}); + +export type SelectSolutionPayload = z.infer; + +export const CreatePrPayloadSchema = z.object({ + type: z.literal("create_pr"), +}); + +export type CreatePrPayload = z.infer; + +export type AutofixUpdatePayload = + | SelectRootCausePayload + | SelectSolutionPayload + | CreatePrPayload; + +// ───────────────────────────────────────────────────────────────────────────── +// Helper Functions +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Check if an autofix status is terminal (no more updates expected) + */ +export function isTerminalStatus(status: string): boolean { + return TERMINAL_STATUSES.includes(status as AutofixStatus); +} + +/** + * Extract root causes from autofix steps + */ +export function extractRootCauses(state: AutofixState): RootCause[] { + if (!state.steps) { + return []; + } + + for (const step of state.steps) { + if (step.key === "root_cause_analysis" && step.causes) { + return step.causes; + } + } + + return []; +} + +/** + * Get the latest progress message from autofix steps + */ +export function getLatestProgress(state: AutofixState): string | undefined { + if (!state.steps) { + return; + } + + // Find the step that's currently processing or most recently updated + for (let i = state.steps.length - 1; i >= 0; i--) { + const step = state.steps[i]; + if (step?.progress && step.progress.length > 0) { + const lastProgress = step.progress.at(-1); + return lastProgress?.message; + } + } + + return; +} + +/** + * Extract PR URL from completed autofix state + */ +export function extractPrUrl(state: AutofixState): string | undefined { + if (!state.steps) { + return; + } + + // Look for PR info in steps or coding_agents + for (const step of state.steps) { + if (step.key === "create_pr" || step.key === "changes") { + // PR URL might be in the step data + const stepData = step as unknown as Record; + if (typeof stepData.pr_url === "string") { + return stepData.pr_url; + } + } + } + + // Check coding_agents for PR info + if (state.coding_agents) { + for (const agent of Object.values(state.coding_agents)) { + const agentData = agent as Record; + if (typeof agentData.pr_url === "string") { + return agentData.pr_url; + } + } + } + + return; +} diff --git a/src/types/index.ts b/src/types/index.ts index d172efc17..0f9948f11 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,31 @@ // DSN types export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; +// Autofix types +export type { + AutofixResponse, + AutofixState, + AutofixStatus, + AutofixStep, + AutofixTriggerResponse, + AutofixUpdatePayload, + RootCause, + StoppingPoint, +} from "./autofix.js"; +export { + AUTOFIX_STATUSES, + AutofixResponseSchema, + AutofixStateSchema, + AutofixStepSchema, + AutofixTriggerResponseSchema, + extractPrUrl, + extractRootCauses, + getLatestProgress, + isTerminalStatus, + RootCauseSchema, + STOPPING_POINTS, + TERMINAL_STATUSES, +} from "./autofix.js"; // Configuration types export type { CachedProject, SentryConfig } from "./config.js"; export { SentryConfigSchema } from "./config.js"; From d708493920dc20446b6ce3f0ca6558c94e9b1530 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 22 Jan 2026 22:15:43 +0530 Subject: [PATCH 02/19] chore: minor change --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index 47a754587..e8a75d9d0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 1, "workspaces": { "": { "name": "sentry", From 1b116e3976d86413802127e9717a100247ab92ea Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 22 Jan 2026 22:32:13 +0530 Subject: [PATCH 03/19] fix: minor changes --- src/commands/issue/explain.ts | 141 ++--------- src/commands/issue/fix.ts | 111 +------- src/commands/issue/utils.ts | 148 +++++++++++ test/commands/issue/utils.test.ts | 378 ++++++++++++++++++++++++++++ test/lib/api-client.autofix.test.ts | 289 +++++++++++++++++++++ test/lib/formatters/autofix.test.ts | 268 ++++++++++++++++++++ test/types/autofix.test.ts | 305 ++++++++++++++++++++++ 7 files changed, 1417 insertions(+), 223 deletions(-) create mode 100644 src/commands/issue/utils.ts create mode 100644 test/commands/issue/utils.test.ts create mode 100644 test/lib/api-client.autofix.test.ts create mode 100644 test/lib/formatters/autofix.test.ts create mode 100644 test/types/autofix.test.ts diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 2978dea88..d168939c6 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -6,29 +6,15 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { - getAutofixState, - getIssueByShortId, - isShortId, - triggerAutofix, -} from "../../lib/api-client.js"; -import { ApiError, ContextError } from "../../lib/errors.js"; +import { triggerAutofix } from "../../lib/api-client.js"; +import { ApiError } from "../../lib/errors.js"; import { formatAutofixError, - formatProgressLine, formatRootCauseList, - getProgressMessage, } from "../../lib/formatters/autofix.js"; import { writeJson } from "../../lib/formatters/index.js"; -import { resolveOrg } from "../../lib/resolve-target.js"; -import { extractRootCauses, isTerminalStatus } from "../../types/autofix.js"; -import type { Writer } from "../../types/index.js"; - -/** Polling interval in milliseconds */ -const POLL_INTERVAL_MS = 3000; - -/** Maximum time to wait for completion in milliseconds (10 minutes) */ -const TIMEOUT_MS = 600_000; +import { extractRootCauses } from "../../types/autofix.js"; +import { pollAutofixState, resolveIssueId } from "./utils.js"; type ExplainFlags = { readonly org?: string; @@ -37,101 +23,6 @@ type ExplainFlags = { readonly json: boolean; }; -/** - * Resolve the numeric issue ID from either a numeric ID or short ID. - * - * @param issueId - User-provided issue ID (numeric or short) - * @param org - Optional org slug for short ID resolution - * @param cwd - Current working directory - * @returns Numeric issue ID - */ -async function resolveIssueId( - issueId: string, - org: string | undefined, - cwd: string -): Promise { - if (!isShortId(issueId)) { - return issueId; - } - - // Short ID requires organization context - const resolved = await resolveOrg({ org, cwd }); - if (!resolved) { - throw new ContextError( - "Organization", - `sentry issue explain ${issueId} --org ` - ); - } - - const issue = await getIssueByShortId(resolved.org, issueId); - return issue.id; -} - -/** - * Poll for autofix completion with progress display. - * - * @param issueId - Numeric issue ID - * @param stdout - Output writer - * @param stderr - Error writer - * @param json - Whether to suppress progress output (for JSON mode) - */ -async function pollUntilComplete( - issueId: string, - _stdout: Writer, - stderr: Writer, - json: boolean -): Promise> { - const startTime = Date.now(); - let tick = 0; - let lastMessage = ""; - - while (Date.now() - startTime < TIMEOUT_MS) { - const state = await getAutofixState(issueId); - - if (!state) { - // No autofix state yet, keep waiting - await Bun.sleep(POLL_INTERVAL_MS); - continue; - } - - // Show progress if not in JSON mode - if (!json) { - const message = getProgressMessage(state); - if (message !== lastMessage) { - // Clear current line and write new progress - stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); - lastMessage = message; - } else { - // Update spinner - stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); - } - tick += 1; - } - - // Check if we're done - if (isTerminalStatus(state.status)) { - if (!json) { - stderr.write("\n"); - } - return state; - } - - // Also check for WAITING_FOR_USER_RESPONSE which means root cause is ready - if (state.status === "WAITING_FOR_USER_RESPONSE") { - if (!json) { - stderr.write("\n"); - } - return state; - } - - await Bun.sleep(POLL_INTERVAL_MS); - } - - throw new Error( - "Analysis timed out after 10 minutes. Check the issue in Sentry web UI." - ); -} - export const explainCommand = buildCommand({ docs: { brief: "Analyze an issue using Seer AI", @@ -192,7 +83,12 @@ export const explainCommand = buildCommand({ try { // Resolve the numeric issue ID - const numericId = await resolveIssueId(issueId, flags.org, cwd); + const numericId = await resolveIssueId( + issueId, + flags.org, + cwd, + `sentry issue explain ${issueId} --org ` + ); if (!flags.json) { stderr.write(`Analyzing issue ${issueId}...\n`); @@ -205,17 +101,12 @@ export const explainCommand = buildCommand({ instruction: flags.instruction, }); - // Poll until complete - const finalState = await pollUntilComplete( - numericId, - stdout, - stderr, - flags.json - ); - - if (!finalState) { - throw new Error("No autofix state returned."); - } + // Poll until complete (stop when root cause is ready) + const finalState = await pollAutofixState(numericId, stderr, flags.json, { + stopOnWaitingForUser: true, + timeoutMessage: + "Analysis timed out after 10 minutes. Check the issue in Sentry web UI.", + }); // Handle errors if (finalState.status === "ERROR") { diff --git a/src/commands/issue/fix.ts b/src/commands/issue/fix.ts index 1fb1aacd3..ff080a8bc 100644 --- a/src/commands/issue/fix.ts +++ b/src/commands/issue/fix.ts @@ -7,37 +7,22 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { - getAutofixState, - getIssueByShortId, - isShortId, - updateAutofix, -} from "../../lib/api-client.js"; -import { ApiError, ContextError, ValidationError } from "../../lib/errors.js"; +import { getAutofixState, updateAutofix } from "../../lib/api-client.js"; +import { ApiError, ValidationError } from "../../lib/errors.js"; import { formatAutofixError, formatPrNotFound, - formatProgressLine, formatPrResult, - getProgressMessage, } from "../../lib/formatters/autofix.js"; import { muted } from "../../lib/formatters/colors.js"; import { writeJson } from "../../lib/formatters/index.js"; -import { resolveOrg } from "../../lib/resolve-target.js"; import { type AutofixState, extractPrUrl, extractRootCauses, - isTerminalStatus, type RootCause, } from "../../types/autofix.js"; -import type { Writer } from "../../types/index.js"; - -/** Polling interval in milliseconds */ -const POLL_INTERVAL_MS = 3000; - -/** Maximum time to wait for PR creation in milliseconds (10 minutes) */ -const TIMEOUT_MS = 600_000; +import { pollAutofixState, resolveIssueId } from "./utils.js"; type FixFlags = { readonly org?: string; @@ -45,30 +30,6 @@ type FixFlags = { readonly json: boolean; }; -/** - * Resolve the numeric issue ID from either a numeric ID or short ID. - */ -async function resolveIssueId( - issueId: string, - org: string | undefined, - cwd: string -): Promise { - if (!isShortId(issueId)) { - return issueId; - } - - const resolved = await resolveOrg({ org, cwd }); - if (!resolved) { - throw new ContextError( - "Organization", - `sentry issue fix ${issueId} --org ` - ); - } - - const issue = await getIssueByShortId(resolved.org, issueId); - return issue.id; -} - /** * Validate that an autofix run exists and has completed root cause analysis. * @@ -157,55 +118,6 @@ function validateCauseSelection( return causeId; } -/** - * Poll for PR creation completion. - */ -async function pollUntilPrCreated( - issueId: string, - _stdout: Writer, - stderr: Writer, - json: boolean -): Promise { - const startTime = Date.now(); - let tick = 0; - let lastMessage = ""; - - while (Date.now() - startTime < TIMEOUT_MS) { - const state = await getAutofixState(issueId); - - if (!state) { - await Bun.sleep(POLL_INTERVAL_MS); - continue; - } - - // Show progress if not in JSON mode - if (!json) { - const message = getProgressMessage(state); - if (message !== lastMessage) { - stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); - lastMessage = message; - } else { - stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); - } - tick += 1; - } - - // Check if we're done - if (isTerminalStatus(state.status)) { - if (!json) { - stderr.write("\n"); - } - return state; - } - - await Bun.sleep(POLL_INTERVAL_MS); - } - - throw new Error( - "PR creation timed out after 10 minutes. Check the issue in Sentry web UI." - ); -} - export const fixCommand = buildCommand({ docs: { brief: "Create a PR with a fix using Seer AI", @@ -263,7 +175,12 @@ export const fixCommand = buildCommand({ try { // Resolve the numeric issue ID - const numericId = await resolveIssueId(issueId, flags.org, cwd); + const numericId = await resolveIssueId( + issueId, + flags.org, + cwd, + `sentry issue fix ${issueId} --org ` + ); // Get current autofix state const currentState = await getAutofixState(numericId); @@ -290,12 +207,10 @@ export const fixCommand = buildCommand({ }); // Poll until PR is created - const finalState = await pollUntilPrCreated( - numericId, - stdout, - stderr, - flags.json - ); + const finalState = await pollAutofixState(numericId, stderr, flags.json, { + timeoutMessage: + "PR creation timed out after 10 minutes. Check the issue in Sentry web UI.", + }); // Handle errors if (finalState.status === "ERROR") { diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts new file mode 100644 index 000000000..205d292dc --- /dev/null +++ b/src/commands/issue/utils.ts @@ -0,0 +1,148 @@ +/** + * Shared utilities for issue commands + * + * Common functionality used by explain, fix, and other issue commands. + */ + +import { + getAutofixState, + getIssueByShortId, + isShortId, +} from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; +import { + formatProgressLine, + getProgressMessage, +} from "../../lib/formatters/autofix.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { type AutofixState, isTerminalStatus } from "../../types/autofix.js"; +import type { Writer } from "../../types/index.js"; + +/** Default polling interval in milliseconds */ +const DEFAULT_POLL_INTERVAL_MS = 3000; + +/** Default timeout in milliseconds (10 minutes) */ +const DEFAULT_TIMEOUT_MS = 600_000; + +/** + * Resolve the numeric issue ID from either a numeric ID or short ID. + * Short IDs (e.g., MYPROJECT-ABC) require organization context. + * + * @param issueId - User-provided issue ID (numeric or short) + * @param org - Optional org slug for short ID resolution + * @param cwd - Current working directory for org resolution + * @param commandHint - Command example for error messages (e.g., "sentry issue explain ISSUE-123 --org ") + * @returns Numeric issue ID + * @throws {ContextError} When short ID provided without resolvable organization + */ +export async function resolveIssueId( + issueId: string, + org: string | undefined, + cwd: string, + commandHint: string +): Promise { + if (!isShortId(issueId)) { + return issueId; + } + + // Short ID requires organization context + const resolved = await resolveOrg({ org, cwd }); + if (!resolved) { + throw new ContextError("Organization", commandHint); + } + + const issue = await getIssueByShortId(resolved.org, issueId); + return issue.id; +} + +type PollOptions = { + /** Polling interval in milliseconds (default: 3000) */ + pollIntervalMs?: number; + /** Maximum time to wait in milliseconds (default: 600000 = 10 minutes) */ + timeoutMs?: number; + /** Custom timeout error message */ + timeoutMessage?: string; + /** Stop polling when status is WAITING_FOR_USER_RESPONSE (default: false) */ + stopOnWaitingForUser?: boolean; +}; + +/** + * Check if polling should stop based on current state. + */ +function shouldStopPolling( + state: AutofixState, + stopOnWaitingForUser: boolean +): boolean { + if (isTerminalStatus(state.status)) { + return true; + } + if (stopOnWaitingForUser && state.status === "WAITING_FOR_USER_RESPONSE") { + return true; + } + return false; +} + +/** + * Update progress display with spinner animation. + */ +function updateProgressDisplay( + stderr: Writer, + state: AutofixState, + tick: number +): void { + const message = getProgressMessage(state); + stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); +} + +/** + * Poll autofix state until completion or timeout. + * Displays progress spinner and messages to stderr when not in JSON mode. + * + * @param issueId - Numeric issue ID + * @param stderr - Writer for progress output + * @param json - Whether to suppress progress output (JSON mode) + * @param options - Polling configuration + * @returns Final autofix state + * @throws {Error} On timeout + */ +export async function pollAutofixState( + issueId: string, + stderr: Writer, + json: boolean, + options: PollOptions = {} +): Promise { + const { + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + timeoutMs = DEFAULT_TIMEOUT_MS, + timeoutMessage = "Operation timed out after 10 minutes. Check the issue in Sentry web UI.", + stopOnWaitingForUser = false, + } = options; + + const startTime = Date.now(); + let tick = 0; + + while (Date.now() - startTime < timeoutMs) { + const state = await getAutofixState(issueId); + + if (!state) { + await Bun.sleep(pollIntervalMs); + continue; + } + + if (!json) { + updateProgressDisplay(stderr, state, tick); + tick += 1; + } + + if (shouldStopPolling(state, stopOnWaitingForUser)) { + if (!json) { + stderr.write("\n"); + } + return state; + } + + await Bun.sleep(pollIntervalMs); + } + + throw new Error(timeoutMessage); +} diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts new file mode 100644 index 000000000..38595c819 --- /dev/null +++ b/test/commands/issue/utils.test.ts @@ -0,0 +1,378 @@ +/** + * Issue Command Utilities Tests + * + * Tests for shared utilities in src/commands/issue/utils.ts + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + pollAutofixState, + resolveIssueId, +} from "../../../src/commands/issue/utils.js"; +import { setAuthToken } from "../../../src/lib/config.js"; + +// Test config directory +let testConfigDir: string; +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + testConfigDir = join( + process.env.SENTRY_CLI_CONFIG_DIR ?? "/tmp", + `test-issue-utils-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testConfigDir, { recursive: true }); + process.env.SENTRY_CLI_CONFIG_DIR = testConfigDir; + + // Save original fetch + originalFetch = globalThis.fetch; + + // Set up auth token + await setAuthToken("test-token"); +}); + +afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; + + try { + rmSync(testConfigDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +describe("resolveIssueId", () => { + test("returns numeric ID unchanged", async () => { + // No API call needed for numeric IDs + const result = await resolveIssueId( + "123456789", + undefined, + "/tmp", + "sentry issue explain 123456789 --org " + ); + + expect(result).toBe("123456789"); + }); + + test("returns numeric-looking ID unchanged", async () => { + const result = await resolveIssueId( + "9999999999", + undefined, + "/tmp", + "sentry issue explain 9999999999 --org " + ); + + expect(result).toBe("9999999999"); + }); + + test("resolves short ID when org is provided", async () => { + // Mock the API calls for short ID resolution + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + // Handle both string URLs and Request objects + const req = new Request(input, init); + const url = req.url; + + // Mock issue lookup by short ID (URL includes /api/0/) + if (url.includes("organizations/my-org/issues/PROJECT-ABC")) { + return new Response( + JSON.stringify({ + id: "987654321", + shortId: "PROJECT-ABC", + title: "Test Issue", + status: "unresolved", + platform: "javascript", + type: "error", + count: "10", + userCount: 5, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const result = await resolveIssueId( + "PROJECT-ABC", + "my-org", + "/tmp", + "sentry issue explain PROJECT-ABC --org " + ); + + expect(result).toBe("987654321"); + }); + + test("throws ContextError when short ID provided without org", async () => { + // Clear any DSN/config that might provide org context + delete process.env.SENTRY_DSN; + + await expect( + resolveIssueId( + "PROJECT-ABC", + undefined, + "/nonexistent/path", + "sentry issue explain PROJECT-ABC --org " + ) + ).rejects.toThrow("Organization"); + }); +}); + +describe("pollAutofixState", () => { + test("returns immediately when state is COMPLETED", async () => { + let fetchCount = 0; + + globalThis.fetch = async () => { + fetchCount += 1; + return new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "COMPLETED", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const mockStderr = { + write: () => { + // Intentionally empty - suppress output in tests + }, + }; + const result = await pollAutofixState("123456789", mockStderr, true); + + expect(result.status).toBe("COMPLETED"); + expect(fetchCount).toBe(1); + }); + + test("returns immediately when state is ERROR", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "ERROR", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const mockStderr = { + write: () => { + // Intentionally empty - suppress output in tests + }, + }; + const result = await pollAutofixState("123456789", mockStderr, true); + + expect(result.status).toBe("ERROR"); + }); + + test("stops at WAITING_FOR_USER_RESPONSE when stopOnWaitingForUser is true", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "WAITING_FOR_USER_RESPONSE", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const mockStderr = { + write: () => { + // Intentionally empty - suppress output in tests + }, + }; + const result = await pollAutofixState("123456789", mockStderr, true, { + stopOnWaitingForUser: true, + }); + + expect(result.status).toBe("WAITING_FOR_USER_RESPONSE"); + }); + + test("continues polling when PROCESSING", async () => { + let fetchCount = 0; + + globalThis.fetch = async () => { + fetchCount += 1; + + // Return PROCESSING for first call, COMPLETED for second + if (fetchCount === 1) { + return new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "PROCESSING", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "COMPLETED", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const mockStderr = { + write: () => { + // Intentionally empty - suppress output in tests + }, + }; + const result = await pollAutofixState("123456789", mockStderr, true, { + pollIntervalMs: 10, // Short interval for test + }); + + expect(result.status).toBe("COMPLETED"); + expect(fetchCount).toBe(2); + }); + + test("writes progress to stderr when not in JSON mode", async () => { + let stderrOutput = ""; + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "COMPLETED", + steps: [ + { + id: "step-1", + key: "analysis", + status: "COMPLETED", + title: "Analysis", + progress: [ + { + message: "Analyzing...", + timestamp: "2025-01-01T00:00:00Z", + }, + ], + }, + ], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const mockStderr = { + write: (s: string) => { + stderrOutput += s; + }, + }; + + await pollAutofixState("123456789", mockStderr, false); + + expect(stderrOutput).toContain("Analyzing"); + }); + + test("throws timeout error when exceeding timeoutMs", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "PROCESSING", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const mockStderr = { + write: () => { + // Intentionally empty - suppress output in tests + }, + }; + + await expect( + pollAutofixState("123456789", mockStderr, true, { + timeoutMs: 50, + pollIntervalMs: 20, + timeoutMessage: "Custom timeout message", + }) + ).rejects.toThrow("Custom timeout message"); + }); + + test("continues polling when autofix is null", async () => { + let fetchCount = 0; + + globalThis.fetch = async () => { + fetchCount += 1; + + // Return null for first call, state for second + if (fetchCount === 1) { + return new Response(JSON.stringify({ autofix: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "COMPLETED", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const mockStderr = { + write: () => { + // Intentionally empty - suppress output in tests + }, + }; + const result = await pollAutofixState("123456789", mockStderr, true, { + pollIntervalMs: 10, + }); + + expect(result.status).toBe("COMPLETED"); + expect(fetchCount).toBe(2); + }); +}); diff --git a/test/lib/api-client.autofix.test.ts b/test/lib/api-client.autofix.test.ts new file mode 100644 index 000000000..f04fe0832 --- /dev/null +++ b/test/lib/api-client.autofix.test.ts @@ -0,0 +1,289 @@ +/** + * Autofix API Client Tests + * + * Tests for the autofix-related API functions by mocking fetch. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + getAutofixState, + triggerAutofix, + updateAutofix, +} from "../../src/lib/api-client.js"; +import { setAuthToken } from "../../src/lib/config.js"; + +// Test config directory +let testConfigDir: string; +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + testConfigDir = join( + process.env.SENTRY_CLI_CONFIG_DIR ?? "/tmp", + `test-autofix-api-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testConfigDir, { recursive: true }); + process.env.SENTRY_CLI_CONFIG_DIR = testConfigDir; + + // Save original fetch + originalFetch = globalThis.fetch; + + // Set up auth token (manual token, no refresh) + await setAuthToken("test-token"); +}); + +afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; + + try { + rmSync(testConfigDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +describe("triggerAutofix", () => { + test("sends POST request to autofix endpoint", async () => { + let capturedRequest: Request | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + + return new Response(JSON.stringify({ run_id: 12_345 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await triggerAutofix("123456789"); + + expect(result.run_id).toBe(12_345); + expect(capturedRequest?.method).toBe("POST"); + expect(capturedRequest?.url).toContain("/issues/123456789/autofix/"); + }); + + test("includes stoppingPoint in request body", async () => { + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedBody = await req.json(); + + return new Response(JSON.stringify({ run_id: 12_345 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await triggerAutofix("123456789", { stoppingPoint: "root_cause" }); + + expect(capturedBody).toEqual({ stoppingPoint: "root_cause" }); + }); + + test("includes optional parameters when provided", async () => { + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedBody = await req.json(); + + return new Response(JSON.stringify({ run_id: 12_345 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await triggerAutofix("123456789", { + stoppingPoint: "open_pr", + eventId: "event-abc", + instruction: "Focus on database issues", + }); + + expect(capturedBody).toEqual({ + stoppingPoint: "open_pr", + eventId: "event-abc", + instruction: "Focus on database issues", + }); + }); + + test("throws ApiError on 402 response", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "No budget for Seer Autofix" }), { + status: 402, + headers: { "Content-Type": "application/json" }, + }); + + await expect(triggerAutofix("123456789")).rejects.toThrow(); + }); + + test("throws ApiError on 403 response", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "AI Autofix is not enabled" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + + await expect(triggerAutofix("123456789")).rejects.toThrow(); + }); +}); + +describe("getAutofixState", () => { + test("sends GET request to autofix endpoint", async () => { + let capturedRequest: Request | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + + return new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "PROCESSING", + steps: [], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const result = await getAutofixState("123456789"); + + expect(result?.run_id).toBe(12_345); + expect(result?.status).toBe("PROCESSING"); + expect(capturedRequest?.method).toBe("GET"); + expect(capturedRequest?.url).toContain("/issues/123456789/autofix/"); + }); + + test("returns null when autofix is null", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ autofix: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + const result = await getAutofixState("123456789"); + expect(result).toBeNull(); + }); + + test("returns completed state with steps", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "COMPLETED", + steps: [ + { + id: "step-1", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause Analysis", + causes: [ + { + id: 0, + description: "Test cause", + }, + ], + }, + ], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("123456789"); + expect(result?.status).toBe("COMPLETED"); + expect(result?.steps).toHaveLength(1); + expect(result?.steps?.[0]?.causes).toHaveLength(1); + }); +}); + +describe("updateAutofix", () => { + test("sends POST request to autofix update endpoint", async () => { + let capturedRequest: Request | undefined; + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + capturedBody = await new Request(input, init).json(); + + return new Response(JSON.stringify({}), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await updateAutofix("123456789", 12_345, { + type: "select_root_cause", + cause_id: 0, + stopping_point: "open_pr", + }); + + expect(capturedRequest?.method).toBe("POST"); + expect(capturedRequest?.url).toContain("/issues/123456789/autofix/update/"); + expect(capturedBody).toEqual({ + run_id: 12_345, + payload: { + type: "select_root_cause", + cause_id: 0, + stopping_point: "open_pr", + }, + }); + }); + + test("sends select_solution payload", async () => { + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = await new Request(input, init).json(); + + return new Response(JSON.stringify({}), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await updateAutofix("123456789", 12_345, { + type: "select_solution", + }); + + expect(capturedBody).toEqual({ + run_id: 12_345, + payload: { + type: "select_solution", + }, + }); + }); + + test("sends create_pr payload", async () => { + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = await new Request(input, init).json(); + + return new Response(JSON.stringify({}), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await updateAutofix("123456789", 12_345, { + type: "create_pr", + }); + + expect(capturedBody).toEqual({ + run_id: 12_345, + payload: { + type: "create_pr", + }, + }); + }); +}); diff --git a/test/lib/formatters/autofix.test.ts b/test/lib/formatters/autofix.test.ts new file mode 100644 index 000000000..48a94aba8 --- /dev/null +++ b/test/lib/formatters/autofix.test.ts @@ -0,0 +1,268 @@ +/** + * Autofix Formatter Tests + * + * Tests for formatting functions in src/lib/formatters/autofix.ts + */ + +import { describe, expect, test } from "bun:test"; +import { + formatAutofixError, + formatAutofixStatus, + formatPrNotFound, + formatProgressLine, + formatPrResult, + formatRootCause, + formatRootCauseHeader, + formatRootCauseList, + getProgressMessage, + getSpinnerFrame, +} from "../../../src/lib/formatters/autofix.js"; +import type { AutofixState, RootCause } from "../../../src/types/autofix.js"; + +describe("getSpinnerFrame", () => { + test("returns a spinner character", () => { + const frame = getSpinnerFrame(0); + expect(typeof frame).toBe("string"); + expect(frame.length).toBeGreaterThan(0); + }); + + test("cycles through frames", () => { + const frame0 = getSpinnerFrame(0); + const frame1 = getSpinnerFrame(1); + const frame10 = getSpinnerFrame(10); + + // Frame 0 and 10 should be the same (assuming 10 frames in the cycle) + expect(frame0).toBe(frame10); + expect(frame0).not.toBe(frame1); + }); +}); + +describe("formatProgressLine", () => { + test("includes message and spinner", () => { + const line = formatProgressLine("Processing...", 0); + expect(line).toContain("Processing..."); + // Should have some character before the message (spinner) + expect(line.length).toBeGreaterThan("Processing...".length); + }); + + test("changes with different tick values", () => { + const line0 = formatProgressLine("Test", 0); + const line1 = formatProgressLine("Test", 1); + expect(line0).not.toBe(line1); + }); +}); + +describe("getProgressMessage", () => { + test("returns progress message from steps", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + steps: [ + { + id: "step-1", + key: "analysis", + status: "PROCESSING", + title: "Analyzing", + progress: [ + { + message: "Figuring out the root cause...", + timestamp: "2025-01-01T00:00:00Z", + }, + ], + }, + ], + }; + + expect(getProgressMessage(state)).toBe("Figuring out the root cause..."); + }); + + test("returns default message when no steps", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + }; + + const message = getProgressMessage(state); + expect(message).toBeTruthy(); + expect(typeof message).toBe("string"); + }); + + test("returns appropriate message based on status", () => { + // Need empty steps array to trigger status-based fallback + const completedState: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [], + }; + + const errorState: AutofixState = { + run_id: 123, + status: "ERROR", + steps: [], + }; + + expect(getProgressMessage(completedState)).toContain("complete"); + expect(getProgressMessage(errorState)).toContain("fail"); + }); +}); + +describe("formatRootCause", () => { + test("formats a basic root cause", () => { + const cause: RootCause = { + id: 0, + description: + "Database connection timeout due to missing pool configuration", + }; + + const lines = formatRootCause(cause, 0); + expect(lines.length).toBeGreaterThan(0); + expect(lines.join("\n")).toContain("Database connection timeout"); + expect(lines.join("\n")).toContain("Cause #0"); + }); + + test("includes relevant repos when present", () => { + const cause: RootCause = { + id: 0, + description: "Test cause", + relevant_repos: ["org/repo1", "org/repo2"], + }; + + const lines = formatRootCause(cause, 0); + const output = lines.join("\n"); + expect(output).toContain("org/repo1"); + }); + + test("includes reproduction steps when present", () => { + const cause: RootCause = { + id: 0, + description: "Test cause", + root_cause_reproduction: [ + { + title: "Step 1", + code_snippet_and_analysis: "User makes API request", + }, + { + title: "Step 2", + code_snippet_and_analysis: "Database query times out", + }, + ], + }; + + const lines = formatRootCause(cause, 0); + const output = lines.join("\n"); + expect(output).toContain("Step 1"); + expect(output).toContain("User makes API request"); + }); +}); + +describe("formatRootCauseHeader", () => { + test("returns array of header lines", () => { + const lines = formatRootCauseHeader(); + expect(Array.isArray(lines)).toBe(true); + expect(lines.length).toBeGreaterThan(0); + expect(lines.join("\n")).toContain("Root Cause"); + }); +}); + +describe("formatRootCauseList", () => { + test("formats single cause with fix hint", () => { + const causes: RootCause[] = [{ id: 0, description: "Single root cause" }]; + + const lines = formatRootCauseList(causes, "ISSUE-123"); + const output = lines.join("\n"); + expect(output).toContain("Single root cause"); + expect(output).toContain("sentry issue fix ISSUE-123"); + expect(output).toContain("--cause 0"); + }); + + test("formats multiple causes with selection hint", () => { + const causes: RootCause[] = [ + { id: 0, description: "First cause" }, + { id: 1, description: "Second cause" }, + ]; + + const lines = formatRootCauseList(causes, "ISSUE-456"); + const output = lines.join("\n"); + expect(output).toContain("First cause"); + expect(output).toContain("Second cause"); + expect(output).toContain("sentry issue fix ISSUE-456 --cause "); + }); + + test("handles empty causes array", () => { + const lines = formatRootCauseList([], "ISSUE-789"); + const output = lines.join("\n"); + expect(output).toContain("No root causes"); + }); +}); + +describe("formatPrResult", () => { + test("formats PR URL display", () => { + const lines = formatPrResult("https://github.com/org/repo/pull/123"); + const output = lines.join("\n"); + expect(output).toContain("Pull Request"); + expect(output).toContain("https://github.com/org/repo/pull/123"); + }); +}); + +describe("formatPrNotFound", () => { + test("returns helpful message when no PR URL", () => { + const lines = formatPrNotFound(); + const output = lines.join("\n"); + expect(output).toContain("no PR URL"); + expect(output).toContain("Sentry web UI"); + }); +}); + +describe("formatAutofixStatus", () => { + test("formats COMPLETED status", () => { + const result = formatAutofixStatus("COMPLETED"); + expect(result.toLowerCase()).toContain("completed"); + }); + + test("formats PROCESSING status", () => { + const result = formatAutofixStatus("PROCESSING"); + expect(result.toLowerCase()).toContain("processing"); + }); + + test("formats ERROR status", () => { + const result = formatAutofixStatus("ERROR"); + expect(result.toLowerCase()).toContain("error"); + }); + + test("formats unknown status", () => { + const result = formatAutofixStatus("UNKNOWN_STATUS"); + expect(result).toBe("UNKNOWN_STATUS"); + }); +}); + +describe("formatAutofixError", () => { + test("formats 402 Payment Required", () => { + const message = formatAutofixError(402); + expect(message.toLowerCase()).toContain("budget"); + }); + + test("formats 403 Forbidden for not enabled", () => { + const message = formatAutofixError(403, "AI Autofix is not enabled"); + expect(message.toLowerCase()).toContain("not enabled"); + }); + + test("formats 403 Forbidden for AI features disabled", () => { + const message = formatAutofixError(403, "AI features are disabled"); + expect(message.toLowerCase()).toContain("disabled"); + }); + + test("formats 404 Not Found", () => { + const message = formatAutofixError(404); + expect(message.toLowerCase()).toContain("not found"); + }); + + test("returns detail message for unknown errors", () => { + const message = formatAutofixError(500, "Internal server error"); + expect(message).toBe("Internal server error"); + }); + + test("returns generic message when no detail", () => { + const message = formatAutofixError(500); + expect(message).toBeTruthy(); + }); +}); diff --git a/test/types/autofix.test.ts b/test/types/autofix.test.ts new file mode 100644 index 000000000..dc12b9d35 --- /dev/null +++ b/test/types/autofix.test.ts @@ -0,0 +1,305 @@ +/** + * Autofix Type Helper Tests + * + * Tests for pure functions in src/types/autofix.ts + */ + +import { describe, expect, test } from "bun:test"; +import { + type AutofixState, + type AutofixStep, + extractPrUrl, + extractRootCauses, + getLatestProgress, + isTerminalStatus, + TERMINAL_STATUSES, +} from "../../src/types/autofix.js"; + +describe("isTerminalStatus", () => { + test("returns true for COMPLETED status", () => { + expect(isTerminalStatus("COMPLETED")).toBe(true); + }); + + test("returns true for ERROR status", () => { + expect(isTerminalStatus("ERROR")).toBe(true); + }); + + test("returns true for CANCELLED status", () => { + expect(isTerminalStatus("CANCELLED")).toBe(true); + }); + + test("returns false for PROCESSING status", () => { + expect(isTerminalStatus("PROCESSING")).toBe(false); + }); + + test("returns false for WAITING_FOR_USER_RESPONSE status", () => { + expect(isTerminalStatus("WAITING_FOR_USER_RESPONSE")).toBe(false); + }); + + test("returns false for unknown status", () => { + expect(isTerminalStatus("UNKNOWN")).toBe(false); + }); + + test("TERMINAL_STATUSES contains expected values", () => { + expect(TERMINAL_STATUSES).toContain("COMPLETED"); + expect(TERMINAL_STATUSES).toContain("ERROR"); + expect(TERMINAL_STATUSES).toContain("CANCELLED"); + expect(TERMINAL_STATUSES).not.toContain("PROCESSING"); + }); +}); + +describe("extractRootCauses", () => { + test("extracts causes from root_cause_analysis step", () => { + const state: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [ + { + id: "step-1", + key: "root_cause_analysis_processing", + status: "COMPLETED", + title: "Analyzing", + }, + { + id: "step-2", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause Analysis", + causes: [ + { + id: 0, + description: "Database connection timeout", + relevant_repos: ["org/repo"], + }, + { + id: 1, + description: "Missing index on query", + }, + ], + }, + ], + }; + + const causes = extractRootCauses(state); + expect(causes).toHaveLength(2); + expect(causes[0]?.description).toBe("Database connection timeout"); + expect(causes[1]?.description).toBe("Missing index on query"); + }); + + test("returns empty array when no steps", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + }; + + expect(extractRootCauses(state)).toEqual([]); + }); + + test("returns empty array when steps is empty", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + steps: [], + }; + + expect(extractRootCauses(state)).toEqual([]); + }); + + test("returns empty array when no root_cause_analysis step", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + steps: [ + { + id: "step-1", + key: "other_step", + status: "COMPLETED", + title: "Other Step", + }, + ], + }; + + expect(extractRootCauses(state)).toEqual([]); + }); + + test("returns empty array when root_cause_analysis has no causes", () => { + const state: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [ + { + id: "step-1", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause Analysis", + }, + ], + }; + + expect(extractRootCauses(state)).toEqual([]); + }); +}); + +describe("getLatestProgress", () => { + test("returns latest progress message from last step", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + steps: [ + { + id: "step-1", + key: "step_1", + status: "COMPLETED", + title: "Step 1", + progress: [ + { message: "First message", timestamp: "2025-01-01T00:00:00Z" }, + ], + }, + { + id: "step-2", + key: "step_2", + status: "PROCESSING", + title: "Step 2", + progress: [ + { message: "Second message", timestamp: "2025-01-01T00:01:00Z" }, + { message: "Latest message", timestamp: "2025-01-01T00:02:00Z" }, + ], + }, + ], + }; + + expect(getLatestProgress(state)).toBe("Latest message"); + }); + + test("returns message from earlier step if later steps have no progress", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + steps: [ + { + id: "step-1", + key: "step_1", + status: "COMPLETED", + title: "Step 1", + progress: [ + { message: "Has progress", timestamp: "2025-01-01T00:00:00Z" }, + ], + }, + { + id: "step-2", + key: "step_2", + status: "PROCESSING", + title: "Step 2", + progress: [], + }, + ], + }; + + expect(getLatestProgress(state)).toBe("Has progress"); + }); + + test("returns undefined when no steps", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + }; + + expect(getLatestProgress(state)).toBeUndefined(); + }); + + test("returns undefined when steps have no progress", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + steps: [ + { + id: "step-1", + key: "step_1", + status: "PROCESSING", + title: "Step 1", + }, + ], + }; + + expect(getLatestProgress(state)).toBeUndefined(); + }); +}); + +describe("extractPrUrl", () => { + test("returns undefined when no steps", () => { + const state: AutofixState = { + run_id: 123, + status: "COMPLETED", + }; + + expect(extractPrUrl(state)).toBeUndefined(); + }); + + test("returns undefined when steps have no PR info", () => { + const state: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [ + { + id: "step-1", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Analysis", + }, + ], + }; + + expect(extractPrUrl(state)).toBeUndefined(); + }); + + test("extracts PR URL from create_pr step", () => { + const state: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [ + { + id: "step-1", + key: "create_pr", + status: "COMPLETED", + title: "Create PR", + pr_url: "https://github.com/org/repo/pull/123", + } as AutofixStep & { pr_url: string }, + ], + }; + + expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/123"); + }); + + test("extracts PR URL from changes step", () => { + const state: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [ + { + id: "step-1", + key: "changes", + status: "COMPLETED", + title: "Changes", + pr_url: "https://github.com/org/repo/pull/456", + } as AutofixStep & { pr_url: string }, + ], + }; + + expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/456"); + }); + + test("extracts PR URL from coding_agents", () => { + const state: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [], + coding_agents: { + agent_1: { + pr_url: "https://github.com/org/repo/pull/789", + }, + }, + }; + + expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/789"); + }); +}); From aae41fe264c0fde866b879d7bfbee4c9ed95a4ec Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 22 Jan 2026 23:53:45 +0530 Subject: [PATCH 04/19] fix: corrected the explain command --- src/commands/issue/explain.ts | 91 +++++------------ src/commands/issue/fix.ts | 14 ++- src/commands/issue/utils.ts | 63 ++++++++++-- src/lib/api-client.ts | 43 ++++++-- src/lib/formatters/index.ts | 1 + src/lib/formatters/summary.ts | 67 ++++++++++++ src/types/index.ts | 4 + src/types/sentry.ts | 42 ++++++++ test/commands/issue/utils.test.ts | 152 +++++++++++++++++++++------- test/lib/api-client.autofix.test.ts | 116 ++++++++++++++++++--- test/lib/formatters/summary.test.ts | 124 +++++++++++++++++++++++ 11 files changed, 581 insertions(+), 136 deletions(-) create mode 100644 src/lib/formatters/summary.ts create mode 100644 test/lib/formatters/summary.test.ts diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index d168939c6..243d8f3fe 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -1,25 +1,19 @@ /** * sentry issue explain * - * Trigger Seer root cause analysis for a Sentry issue. + * Get an AI-generated summary and analysis of a Sentry issue. */ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { triggerAutofix } from "../../lib/api-client.js"; +import { getIssueSummary } from "../../lib/api-client.js"; import { ApiError } from "../../lib/errors.js"; -import { - formatAutofixError, - formatRootCauseList, -} from "../../lib/formatters/autofix.js"; import { writeJson } from "../../lib/formatters/index.js"; -import { extractRootCauses } from "../../types/autofix.js"; -import { pollAutofixState, resolveIssueId } from "./utils.js"; +import { formatIssueSummary } from "../../lib/formatters/summary.js"; +import { resolveOrgAndIssueId } from "./utils.js"; type ExplainFlags = { readonly org?: string; - readonly event?: string; - readonly instruction?: string; readonly json: boolean; }; @@ -27,14 +21,16 @@ export const explainCommand = buildCommand({ docs: { brief: "Analyze an issue using Seer AI", fullDescription: - "Trigger Seer's AI-powered root cause analysis for a Sentry issue.\n\n" + - "This command starts an analysis that identifies the root cause of the issue " + - "and shows reproduction steps. Once complete, you can use 'sentry issue fix' " + - "to create a pull request with the fix.\n\n" + + "Get an AI-generated summary and root cause analysis for a Sentry issue.\n\n" + + "This command uses Seer AI to analyze the issue and provide:\n" + + " - A headline summary of what's happening\n" + + " - What's wrong with the code\n" + + " - Stack trace analysis\n" + + " - Possible root cause\n\n" + "Examples:\n" + " sentry issue explain 123456789\n" + " sentry issue explain MYPROJECT-ABC --org my-org\n" + - " sentry issue explain 123456789 --instruction 'Focus on the database query'", + " sentry issue explain 123456789 --json", }, parameters: { positional: { @@ -54,19 +50,6 @@ export const explainCommand = buildCommand({ "Organization slug (required for short IDs if not auto-detected)", optional: true, }, - event: { - kind: "parsed", - parse: String, - brief: - "Specific event ID to analyze (uses recommended event if not provided)", - optional: true, - }, - instruction: { - kind: "parsed", - parse: String, - brief: "Custom instruction to guide the analysis", - optional: true, - }, json: { kind: "boolean", brief: "Output as JSON", @@ -82,8 +65,8 @@ export const explainCommand = buildCommand({ const { stdout, stderr, cwd } = this; try { - // Resolve the numeric issue ID - const numericId = await resolveIssueId( + // Resolve org and issue ID + const { org, issueId: numericId } = await resolveOrgAndIssueId( issueId, flags.org, cwd, @@ -94,52 +77,30 @@ export const explainCommand = buildCommand({ stderr.write(`Analyzing issue ${issueId}...\n`); } - // Trigger the autofix with root_cause stopping point - await triggerAutofix(numericId, { - stoppingPoint: "root_cause", - eventId: flags.event, - instruction: flags.instruction, - }); - - // Poll until complete (stop when root cause is ready) - const finalState = await pollAutofixState(numericId, stderr, flags.json, { - stopOnWaitingForUser: true, - timeoutMessage: - "Analysis timed out after 10 minutes. Check the issue in Sentry web UI.", - }); - - // Handle errors - if (finalState.status === "ERROR") { - throw new Error( - "Root cause analysis failed. Check the Sentry web UI for details." - ); - } - - if (finalState.status === "CANCELLED") { - throw new Error("Root cause analysis was cancelled."); - } - - // Extract root causes - const rootCauses = extractRootCauses(finalState); + // Get the AI-generated summary + const summary = await getIssueSummary(org, numericId); // Output results if (flags.json) { - writeJson(stdout, { - run_id: finalState.run_id, - status: finalState.status, - causes: rootCauses, - }); + writeJson(stdout, summary); return; } // Human-readable output - const lines = formatRootCauseList(rootCauses, issueId); + const lines = formatIssueSummary(summary); stdout.write(`${lines.join("\n")}\n`); } catch (error) { // Handle API errors with friendly messages if (error instanceof ApiError) { - const message = formatAutofixError(error.status, error.detail); - throw new Error(message); + if (error.status === 404) { + throw new Error( + "Issue not found, or AI summaries are not available for this issue." + ); + } + if (error.status === 403) { + throw new Error("AI features are not enabled for this organization."); + } + throw new Error(error.detail ?? "Failed to analyze issue."); } throw error; } diff --git a/src/commands/issue/fix.ts b/src/commands/issue/fix.ts index ff080a8bc..c95274db8 100644 --- a/src/commands/issue/fix.ts +++ b/src/commands/issue/fix.ts @@ -22,7 +22,7 @@ import { extractRootCauses, type RootCause, } from "../../types/autofix.js"; -import { pollAutofixState, resolveIssueId } from "./utils.js"; +import { pollAutofixState, resolveOrgAndIssueId } from "./utils.js"; type FixFlags = { readonly org?: string; @@ -174,8 +174,8 @@ export const fixCommand = buildCommand({ const { stdout, stderr, cwd } = this; try { - // Resolve the numeric issue ID - const numericId = await resolveIssueId( + // Resolve org and issue ID + const { org, issueId: numericId } = await resolveOrgAndIssueId( issueId, flags.org, cwd, @@ -183,7 +183,7 @@ export const fixCommand = buildCommand({ ); // Get current autofix state - const currentState = await getAutofixState(numericId); + const currentState = await getAutofixState(org, numericId); // Validate we have a completed root cause analysis const { state, causes } = validateAutofixState(currentState, issueId); @@ -207,7 +207,11 @@ export const fixCommand = buildCommand({ }); // Poll until PR is created - const finalState = await pollAutofixState(numericId, stderr, flags.json, { + const finalState = await pollAutofixState({ + orgSlug: org, + issueId: numericId, + stderr, + json: flags.json, timeoutMessage: "PR creation timed out after 10 minutes. Check the issue in Sentry web UI.", }); diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 205d292dc..31da50ba8 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -55,7 +55,54 @@ export async function resolveIssueId( return issue.id; } -type PollOptions = { +type ResolvedIssue = { + /** Resolved organization slug */ + org: string; + /** Numeric issue ID */ + issueId: string; +}; + +/** + * Resolve both organization slug and numeric issue ID. + * Required for endpoints that need both (like /summarize/). + * + * @param issueId - User-provided issue ID (numeric or short) + * @param org - Optional org slug + * @param cwd - Current working directory for org resolution + * @param commandHint - Command example for error messages + * @returns Object with org slug and numeric issue ID + * @throws {ContextError} When organization cannot be resolved + */ +export async function resolveOrgAndIssueId( + issueId: string, + org: string | undefined, + cwd: string, + commandHint: string +): Promise { + // Always need org for endpoints like /summarize/ + const resolved = await resolveOrg({ org, cwd }); + if (!resolved) { + throw new ContextError("Organization", commandHint); + } + + // If it's a short ID, resolve to numeric ID + if (isShortId(issueId)) { + const issue = await getIssueByShortId(resolved.org, issueId); + return { org: resolved.org, issueId: issue.id }; + } + + return { org: resolved.org, issueId }; +} + +type PollAutofixOptions = { + /** Organization slug */ + orgSlug: string; + /** Numeric issue ID */ + issueId: string; + /** Writer for progress output */ + stderr: Writer; + /** Whether to suppress progress output (JSON mode) */ + json: boolean; /** Polling interval in milliseconds (default: 3000) */ pollIntervalMs?: number; /** Maximum time to wait in milliseconds (default: 600000 = 10 minutes) */ @@ -98,20 +145,18 @@ function updateProgressDisplay( * Poll autofix state until completion or timeout. * Displays progress spinner and messages to stderr when not in JSON mode. * - * @param issueId - Numeric issue ID - * @param stderr - Writer for progress output - * @param json - Whether to suppress progress output (JSON mode) * @param options - Polling configuration * @returns Final autofix state * @throws {Error} On timeout */ export async function pollAutofixState( - issueId: string, - stderr: Writer, - json: boolean, - options: PollOptions = {} + options: PollAutofixOptions ): Promise { const { + orgSlug, + issueId, + stderr, + json, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, timeoutMs = DEFAULT_TIMEOUT_MS, timeoutMessage = "Operation timed out after 10 minutes. Check the issue in Sentry web UI.", @@ -122,7 +167,7 @@ export async function pollAutofixState( let tick = 0; while (Date.now() - startTime < timeoutMs) { - const state = await getAutofixState(issueId); + const state = await getAutofixState(orgSlug, issueId); if (!state) { await Bun.sleep(pollIntervalMs); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 9fa3d6547..0edf11f9f 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -17,6 +17,8 @@ import { type StoppingPoint, } from "../types/autofix.js"; import { + type IssueSummary, + IssueSummarySchema, type SentryEvent, SentryEventSchema, type SentryIssue, @@ -433,6 +435,7 @@ type TriggerAutofixOptions = { * @throws {ApiError} On API errors (402 = no budget, 403 = not enabled) */ export function triggerAutofix( + orgSlug: string, issueId: string, options: TriggerAutofixOptions = {} ): Promise { @@ -448,11 +451,14 @@ export function triggerAutofix( body.instruction = options.instruction; } - return apiRequest(`/issues/${issueId}/autofix/`, { - method: "POST", - body, - schema: AutofixTriggerResponseSchema, - }); + return apiRequest( + `organizations/${orgSlug}/issues/${issueId}/autofix/`, + { + method: "POST", + body, + schema: AutofixTriggerResponseSchema, + } + ); } /** @@ -462,10 +468,11 @@ export function triggerAutofix( * @returns The autofix state, or null if no autofix has been run */ export async function getAutofixState( + orgSlug: string, issueId: string ): Promise { const response = await apiRequest( - `/issues/${issueId}/autofix/`, + `/organizations/${orgSlug}/issues/${issueId}/autofix/`, { schema: AutofixResponseSchema, } @@ -494,3 +501,27 @@ export function updateAutofix( }, }); } + +// ───────────────────────────────────────────────────────────────────────────── +// Issue Summary API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Get an AI-generated summary of an issue. + * + * @param orgSlug - The organization slug + * @param issueId - The numeric Sentry issue ID + * @returns The issue summary with headline, cause analysis, and scores + */ +export function getIssueSummary( + orgSlug: string, + issueId: string +): Promise { + return apiRequest( + `/organizations/${orgSlug}/issues/${issueId}/summarize/`, + { + method: "POST", + schema: IssueSummarySchema, + } + ); +} diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index b07533b1c..c62de98de 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -10,3 +10,4 @@ export * from "./colors.js"; export * from "./human.js"; export * from "./json.js"; export * from "./output.js"; +export * from "./summary.js"; diff --git a/src/lib/formatters/summary.ts b/src/lib/formatters/summary.ts new file mode 100644 index 000000000..163adb1e0 --- /dev/null +++ b/src/lib/formatters/summary.ts @@ -0,0 +1,67 @@ +/** + * Issue Summary Output Formatters + * + * Formatting utilities for AI-generated issue summaries. + */ + +import chalk from "chalk"; +import type { IssueSummary } from "../../types/index.js"; +import { cyan, green, muted, yellow } from "./colors.js"; + +const bold = (text: string): string => chalk.bold(text); + +/** + * Format an issue summary for human-readable output. + * + * @param summary - The issue summary from the API + * @returns Array of formatted lines + */ +export function formatIssueSummary(summary: IssueSummary): string[] { + const lines: string[] = []; + + // Headline + lines.push(""); + lines.push(bold(summary.headline)); + lines.push(""); + + // What's Wrong + if (summary.whatsWrong) { + lines.push(yellow("What's Wrong:")); + lines.push(` ${summary.whatsWrong}`); + lines.push(""); + } + + // Trace + if (summary.trace) { + lines.push(cyan("Trace:")); + lines.push(` ${summary.trace}`); + lines.push(""); + } + + // Possible Cause + if (summary.possibleCause) { + lines.push(green("Possible Cause:")); + lines.push(` ${summary.possibleCause}`); + lines.push(""); + } + + // Confidence score + if (summary.scores?.possibleCauseConfidence !== null) { + const confidence = summary.scores?.possibleCauseConfidence; + if (confidence !== undefined) { + const percent = Math.round(confidence * 100); + lines.push(muted(`Confidence: ${percent}%`)); + } + } + + return lines; +} + +/** + * Format an issue summary header for display. + * + * @returns Array of header lines + */ +export function formatSummaryHeader(): string[] { + return [bold("Issue Summary"), "═".repeat(60)]; +} diff --git a/src/types/index.ts b/src/types/index.ts index 0f9948f11..7af804f71 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,6 +52,8 @@ export { export type { IssueLevel, IssueStatus, + IssueSummary, + IssueSummaryScores, SentryEvent, SentryIssue, SentryOrganization, @@ -60,6 +62,8 @@ export type { export { ISSUE_LEVELS, ISSUE_STATUSES, + IssueSummarySchema, + IssueSummaryScoresSchema, SentryEventSchema, SentryIssueSchema, SentryOrganizationSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 8b0b0ff94..092e79cfd 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -224,3 +224,45 @@ export const SentryEventSchema = z .passthrough(); export type SentryEvent = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Issue Summary (AI-generated analysis) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Scores from the AI analysis indicating confidence and fixability. + */ +export const IssueSummaryScoresSchema = z + .object({ + possibleCauseConfidence: z.number().nullable(), + possibleCauseNovelty: z.number().nullable(), + isFixable: z.boolean().nullable(), + fixabilityScore: z.number().nullable(), + fixabilityScoreVersion: z.string().nullable(), + }) + .passthrough(); + +/** + * AI-generated summary of an issue from the /summarize/ endpoint. + */ +export const IssueSummarySchema = z + .object({ + /** Issue group ID */ + groupId: z.string(), + /** One-line summary of the issue */ + headline: z.string(), + /** Description of what's wrong */ + whatsWrong: z.string().optional(), + /** Trace analysis */ + trace: z.string().optional(), + /** Possible cause of the issue */ + possibleCause: z.string().optional(), + /** Confidence and fixability scores */ + scores: IssueSummaryScoresSchema.optional(), + /** Event ID that was analyzed */ + eventId: z.string().optional(), + }) + .passthrough(); + +export type IssueSummary = z.infer; +export type IssueSummaryScores = z.infer; diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 38595c819..39914f48a 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import { pollAutofixState, resolveIssueId, + resolveOrgAndIssueId, } from "../../../src/commands/issue/utils.js"; import { setAuthToken } from "../../../src/lib/config.js"; @@ -124,7 +125,80 @@ describe("resolveIssueId", () => { }); }); +describe("resolveOrgAndIssueId", () => { + test("returns org and numeric issue ID when org is provided", async () => { + const result = await resolveOrgAndIssueId( + "123456789", + "my-org", + "/tmp", + "sentry issue explain 123456789 --org " + ); + + expect(result.org).toBe("my-org"); + expect(result.issueId).toBe("123456789"); + }); + + test("resolves short ID to numeric ID", async () => { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("organizations/my-org/issues/PROJECT-ABC")) { + return new Response( + JSON.stringify({ + id: "987654321", + shortId: "PROJECT-ABC", + title: "Test Issue", + status: "unresolved", + platform: "javascript", + type: "error", + count: "10", + userCount: 5, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const result = await resolveOrgAndIssueId( + "PROJECT-ABC", + "my-org", + "/tmp", + "sentry issue explain PROJECT-ABC --org " + ); + + expect(result.org).toBe("my-org"); + expect(result.issueId).toBe("987654321"); + }); + + test("throws ContextError when org cannot be resolved", async () => { + delete process.env.SENTRY_DSN; + + await expect( + resolveOrgAndIssueId( + "123456789", + undefined, + "/nonexistent/path", + "sentry issue explain 123456789 --org " + ) + ).rejects.toThrow("Organization"); + }); +}); + describe("pollAutofixState", () => { + const mockStderr = { + write: () => { + // Intentionally empty - suppress output in tests + }, + }; + test("returns immediately when state is COMPLETED", async () => { let fetchCount = 0; @@ -145,12 +219,12 @@ describe("pollAutofixState", () => { ); }; - const mockStderr = { - write: () => { - // Intentionally empty - suppress output in tests - }, - }; - const result = await pollAutofixState("123456789", mockStderr, true); + const result = await pollAutofixState({ + orgSlug: "test-org", + issueId: "123456789", + stderr: mockStderr, + json: true, + }); expect(result.status).toBe("COMPLETED"); expect(fetchCount).toBe(1); @@ -172,12 +246,12 @@ describe("pollAutofixState", () => { } ); - const mockStderr = { - write: () => { - // Intentionally empty - suppress output in tests - }, - }; - const result = await pollAutofixState("123456789", mockStderr, true); + const result = await pollAutofixState({ + orgSlug: "test-org", + issueId: "123456789", + stderr: mockStderr, + json: true, + }); expect(result.status).toBe("ERROR"); }); @@ -198,12 +272,11 @@ describe("pollAutofixState", () => { } ); - const mockStderr = { - write: () => { - // Intentionally empty - suppress output in tests - }, - }; - const result = await pollAutofixState("123456789", mockStderr, true, { + const result = await pollAutofixState({ + orgSlug: "test-org", + issueId: "123456789", + stderr: mockStderr, + json: true, stopOnWaitingForUser: true, }); @@ -248,12 +321,11 @@ describe("pollAutofixState", () => { ); }; - const mockStderr = { - write: () => { - // Intentionally empty - suppress output in tests - }, - }; - const result = await pollAutofixState("123456789", mockStderr, true, { + const result = await pollAutofixState({ + orgSlug: "test-org", + issueId: "123456789", + stderr: mockStderr, + json: true, pollIntervalMs: 10, // Short interval for test }); @@ -292,13 +364,18 @@ describe("pollAutofixState", () => { } ); - const mockStderr = { + const stderrMock = { write: (s: string) => { stderrOutput += s; }, }; - await pollAutofixState("123456789", mockStderr, false); + await pollAutofixState({ + orgSlug: "test-org", + issueId: "123456789", + stderr: stderrMock, + json: false, + }); expect(stderrOutput).toContain("Analyzing"); }); @@ -319,14 +396,12 @@ describe("pollAutofixState", () => { } ); - const mockStderr = { - write: () => { - // Intentionally empty - suppress output in tests - }, - }; - await expect( - pollAutofixState("123456789", mockStderr, true, { + pollAutofixState({ + orgSlug: "test-org", + issueId: "123456789", + stderr: mockStderr, + json: true, timeoutMs: 50, pollIntervalMs: 20, timeoutMessage: "Custom timeout message", @@ -363,12 +438,11 @@ describe("pollAutofixState", () => { ); }; - const mockStderr = { - write: () => { - // Intentionally empty - suppress output in tests - }, - }; - const result = await pollAutofixState("123456789", mockStderr, true, { + const result = await pollAutofixState({ + orgSlug: "test-org", + issueId: "123456789", + stderr: mockStderr, + json: true, pollIntervalMs: 10, }); diff --git a/test/lib/api-client.autofix.test.ts b/test/lib/api-client.autofix.test.ts index f04fe0832..01f4f7278 100644 --- a/test/lib/api-client.autofix.test.ts +++ b/test/lib/api-client.autofix.test.ts @@ -1,7 +1,7 @@ /** - * Autofix API Client Tests + * Autofix and Summary API Client Tests * - * Tests for the autofix-related API functions by mocking fetch. + * Tests for the autofix-related and summary API functions by mocking fetch. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -9,6 +9,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { getAutofixState, + getIssueSummary, triggerAutofix, updateAutofix, } from "../../src/lib/api-client.js"; @@ -57,11 +58,13 @@ describe("triggerAutofix", () => { }); }; - const result = await triggerAutofix("123456789"); + const result = await triggerAutofix("test-org", "123456789"); expect(result.run_id).toBe(12_345); expect(capturedRequest?.method).toBe("POST"); - expect(capturedRequest?.url).toContain("/issues/123456789/autofix/"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); }); test("includes stoppingPoint in request body", async () => { @@ -77,7 +80,9 @@ describe("triggerAutofix", () => { }); }; - await triggerAutofix("123456789", { stoppingPoint: "root_cause" }); + await triggerAutofix("test-org", "123456789", { + stoppingPoint: "root_cause", + }); expect(capturedBody).toEqual({ stoppingPoint: "root_cause" }); }); @@ -95,7 +100,7 @@ describe("triggerAutofix", () => { }); }; - await triggerAutofix("123456789", { + await triggerAutofix("test-org", "123456789", { stoppingPoint: "open_pr", eventId: "event-abc", instruction: "Focus on database issues", @@ -115,7 +120,7 @@ describe("triggerAutofix", () => { headers: { "Content-Type": "application/json" }, }); - await expect(triggerAutofix("123456789")).rejects.toThrow(); + await expect(triggerAutofix("test-org", "123456789")).rejects.toThrow(); }); test("throws ApiError on 403 response", async () => { @@ -125,7 +130,7 @@ describe("triggerAutofix", () => { headers: { "Content-Type": "application/json" }, }); - await expect(triggerAutofix("123456789")).rejects.toThrow(); + await expect(triggerAutofix("test-org", "123456789")).rejects.toThrow(); }); }); @@ -151,12 +156,14 @@ describe("getAutofixState", () => { ); }; - const result = await getAutofixState("123456789"); + const result = await getAutofixState("test-org", "123456789"); expect(result?.run_id).toBe(12_345); expect(result?.status).toBe("PROCESSING"); expect(capturedRequest?.method).toBe("GET"); - expect(capturedRequest?.url).toContain("/issues/123456789/autofix/"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); }); test("returns null when autofix is null", async () => { @@ -166,7 +173,7 @@ describe("getAutofixState", () => { headers: { "Content-Type": "application/json" }, }); - const result = await getAutofixState("123456789"); + const result = await getAutofixState("test-org", "123456789"); expect(result).toBeNull(); }); @@ -199,7 +206,7 @@ describe("getAutofixState", () => { } ); - const result = await getAutofixState("123456789"); + const result = await getAutofixState("test-org", "123456789"); expect(result?.status).toBe("COMPLETED"); expect(result?.steps).toHaveLength(1); expect(result?.steps?.[0]?.causes).toHaveLength(1); @@ -287,3 +294,88 @@ describe("updateAutofix", () => { }); }); }); + +describe("getIssueSummary", () => { + test("sends POST request to summarize endpoint", async () => { + let capturedRequest: Request | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + + return new Response( + JSON.stringify({ + groupId: "123456789", + headline: "Test Issue Summary", + whatsWrong: "Something went wrong", + trace: "Error in function X", + possibleCause: "Missing null check", + scores: { + possibleCauseConfidence: 0.85, + possibleCauseNovelty: 0.6, + isFixable: true, + fixabilityScore: 0.7, + fixabilityScoreVersion: "1.0", + }, + eventId: "abc123", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const result = await getIssueSummary("test-org", "123456789"); + + expect(result.groupId).toBe("123456789"); + expect(result.headline).toBe("Test Issue Summary"); + expect(result.whatsWrong).toBe("Something went wrong"); + expect(result.possibleCause).toBe("Missing null check"); + expect(result.scores?.possibleCauseConfidence).toBe(0.85); + expect(capturedRequest?.method).toBe("POST"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/summarize/" + ); + }); + + test("returns summary with minimal fields", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + groupId: "123456789", + headline: "Simple Error", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getIssueSummary("test-org", "123456789"); + + expect(result.groupId).toBe("123456789"); + expect(result.headline).toBe("Simple Error"); + expect(result.whatsWrong).toBeUndefined(); + expect(result.scores).toBeUndefined(); + }); + + test("throws ApiError on 404 response", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "Issue not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + + await expect(getIssueSummary("test-org", "123456789")).rejects.toThrow(); + }); + + test("throws ApiError on 403 response", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "AI features not enabled" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + + await expect(getIssueSummary("test-org", "123456789")).rejects.toThrow(); + }); +}); diff --git a/test/lib/formatters/summary.test.ts b/test/lib/formatters/summary.test.ts new file mode 100644 index 000000000..3bb574927 --- /dev/null +++ b/test/lib/formatters/summary.test.ts @@ -0,0 +1,124 @@ +/** + * Issue Summary Formatter Tests + * + * Tests for formatting functions in src/lib/formatters/summary.ts + */ + +import { describe, expect, test } from "bun:test"; +import { + formatIssueSummary, + formatSummaryHeader, +} from "../../../src/lib/formatters/summary.js"; +import type { IssueSummary } from "../../../src/types/index.js"; + +describe("formatIssueSummary", () => { + test("formats summary with all fields", () => { + const summary: IssueSummary = { + groupId: "123456789", + headline: "Database Connection Timeout", + whatsWrong: "Connection pool exhausted due to missing cleanup", + trace: "Timeout occurred in db.query() after 30s", + possibleCause: "Connection leak in transaction handling", + scores: { + possibleCauseConfidence: 0.85, + possibleCauseNovelty: 0.6, + isFixable: true, + fixabilityScore: 0.7, + fixabilityScoreVersion: "1.0", + }, + eventId: "abc123", + }; + + const lines = formatIssueSummary(summary); + const output = lines.join("\n"); + + expect(output).toContain("Database Connection Timeout"); + expect(output).toContain("What's Wrong:"); + expect(output).toContain("Connection pool exhausted"); + expect(output).toContain("Trace:"); + expect(output).toContain("Timeout occurred"); + expect(output).toContain("Possible Cause:"); + expect(output).toContain("Connection leak"); + expect(output).toContain("Confidence: 85%"); + }); + + test("formats summary with only headline", () => { + const summary: IssueSummary = { + groupId: "123456789", + headline: "Simple Error", + }; + + const lines = formatIssueSummary(summary); + const output = lines.join("\n"); + + expect(output).toContain("Simple Error"); + expect(output).not.toContain("What's Wrong:"); + expect(output).not.toContain("Trace:"); + expect(output).not.toContain("Possible Cause:"); + }); + + test("formats summary without scores", () => { + const summary: IssueSummary = { + groupId: "123456789", + headline: "Test Error", + whatsWrong: "Something went wrong", + }; + + const lines = formatIssueSummary(summary); + const output = lines.join("\n"); + + expect(output).toContain("Test Error"); + expect(output).toContain("Something went wrong"); + expect(output).not.toContain("Confidence:"); + }); + + test("formats summary with null confidence score", () => { + const summary: IssueSummary = { + groupId: "123456789", + headline: "Test Error", + scores: { + possibleCauseConfidence: null, + possibleCauseNovelty: null, + isFixable: null, + fixabilityScore: null, + fixabilityScoreVersion: null, + }, + }; + + const lines = formatIssueSummary(summary); + const output = lines.join("\n"); + + expect(output).toContain("Test Error"); + // Should not show confidence when it's null + expect(output).not.toContain("Confidence:"); + }); + + test("rounds confidence percentage", () => { + const summary: IssueSummary = { + groupId: "123456789", + headline: "Test Error", + scores: { + possibleCauseConfidence: 0.567, + possibleCauseNovelty: null, + isFixable: null, + fixabilityScore: null, + fixabilityScoreVersion: null, + }, + }; + + const lines = formatIssueSummary(summary); + const output = lines.join("\n"); + + expect(output).toContain("Confidence: 57%"); + }); +}); + +describe("formatSummaryHeader", () => { + test("returns header lines", () => { + const lines = formatSummaryHeader(); + + expect(Array.isArray(lines)).toBe(true); + expect(lines.length).toBeGreaterThan(0); + expect(lines.join("\n")).toContain("Issue Summary"); + }); +}); From ce6b2515e717e302556b1a994b7bcf959cffbe27 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 02:10:51 +0530 Subject: [PATCH 05/19] fix: improved the explain command --- src/commands/issue/explain.ts | 80 ++++++++----- src/commands/issue/utils.ts | 66 +++++------ src/lib/api-client.ts | 58 +++------- src/lib/formatters/autofix.ts | 104 +++++++++++++++-- src/types/autofix.ts | 129 +++++++++++++++++++++ src/types/index.ts | 13 +++ test/commands/issue/utils.test.ts | 55 ++++++--- test/lib/api-client.autofix.test.ts | 153 +++++++++++++++++++++++++ test/lib/formatters/autofix.test.ts | 167 +++++++++++++++++++++++++++- test/types/autofix.test.ts | 135 ++++++++++++++++++++++ 10 files changed, 827 insertions(+), 133 deletions(-) diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 243d8f3fe..b15d70375 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -1,16 +1,20 @@ /** * sentry issue explain * - * Get an AI-generated summary and analysis of a Sentry issue. + * Get root cause analysis for a Sentry issue using Seer AI. */ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getIssueSummary } from "../../lib/api-client.js"; +import { getAutofixState, triggerAutofix } from "../../lib/api-client.js"; import { ApiError } from "../../lib/errors.js"; +import { + formatAutofixError, + formatRootCauseList, +} from "../../lib/formatters/autofix.js"; import { writeJson } from "../../lib/formatters/index.js"; -import { formatIssueSummary } from "../../lib/formatters/summary.js"; -import { resolveOrgAndIssueId } from "./utils.js"; +import { extractRootCauses } from "../../types/autofix.js"; +import { pollAutofixState, resolveOrgAndIssueId } from "./utils.js"; type ExplainFlags = { readonly org?: string; @@ -19,14 +23,14 @@ type ExplainFlags = { export const explainCommand = buildCommand({ docs: { - brief: "Analyze an issue using Seer AI", + brief: "Analyze an issue's root cause using Seer AI", fullDescription: - "Get an AI-generated summary and root cause analysis for a Sentry issue.\n\n" + - "This command uses Seer AI to analyze the issue and provide:\n" + - " - A headline summary of what's happening\n" + - " - What's wrong with the code\n" + - " - Stack trace analysis\n" + - " - Possible root cause\n\n" + + "Get a root cause analysis for a Sentry issue using Seer AI.\n\n" + + "This command analyzes the issue and provides:\n" + + " - Identified root causes\n" + + " - Reproduction steps\n" + + " - Relevant code locations\n\n" + + "The analysis may take a few minutes for new issues.\n\n" + "Examples:\n" + " sentry issue explain 123456789\n" + " sentry issue explain MYPROJECT-ABC --org my-org\n" + @@ -73,34 +77,56 @@ export const explainCommand = buildCommand({ `sentry issue explain ${issueId} --org ` ); - if (!flags.json) { - stderr.write(`Analyzing issue ${issueId}...\n`); + // 1. Check for existing analysis + let state = await getAutofixState(org, numericId); + + // Handle error status, we are gonna retry the analysis + if (state?.status === "ERROR") { + stderr.write("Root cause analysis failed, retrying...\n"); + state = null; } - // Get the AI-generated summary - const summary = await getIssueSummary(org, numericId); + // 2. Trigger new analysis if none exists + if (!state) { + if (!flags.json) { + stderr.write("Starting root cause analysis...\n"); + } + await triggerAutofix(org, numericId); + } + + // 3. Poll until complete (if not already completed) + if (!state || state.status !== "COMPLETED") { + state = await pollAutofixState({ + orgSlug: org, + issueId: numericId, + stderr, + json: flags.json, + stopOnWaitingForUser: true, + }); + } - // Output results + // 4. 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." + ); + } + + // 5. Output results if (flags.json) { - writeJson(stdout, summary); + writeJson(stdout, causes); return; } // Human-readable output - const lines = formatIssueSummary(summary); + const lines = formatRootCauseList(causes, issueId); stdout.write(`${lines.join("\n")}\n`); } catch (error) { // Handle API errors with friendly messages if (error instanceof ApiError) { - if (error.status === 404) { - throw new Error( - "Issue not found, or AI summaries are not available for this issue." - ); - } - if (error.status === 403) { - throw new Error("AI features are not enabled for this organization."); - } - throw new Error(error.detail ?? "Failed to analyze issue."); + throw new Error(formatAutofixError(error.status, error.detail)); } throw error; } diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 31da50ba8..f55d94911 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -13,13 +13,17 @@ import { ContextError } from "../../lib/errors.js"; import { formatProgressLine, getProgressMessage, + truncateProgressMessage, } from "../../lib/formatters/autofix.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { type AutofixState, isTerminalStatus } from "../../types/autofix.js"; import type { Writer } from "../../types/index.js"; /** Default polling interval in milliseconds */ -const DEFAULT_POLL_INTERVAL_MS = 3000; +const DEFAULT_POLL_INTERVAL_MS = 1000; + +/** Animation interval for spinner updates (independent of polling) */ +const ANIMATION_INTERVAL_MS = 80; /** Default timeout in milliseconds (10 minutes) */ const DEFAULT_TIMEOUT_MS = 600_000; @@ -129,21 +133,10 @@ function shouldStopPolling( return false; } -/** - * Update progress display with spinner animation. - */ -function updateProgressDisplay( - stderr: Writer, - state: AutofixState, - tick: number -): void { - const message = getProgressMessage(state); - stderr.write(`\r\x1b[K${formatProgressLine(message, tick)}`); -} - /** * Poll autofix state until completion or timeout. * Displays progress spinner and messages to stderr when not in JSON mode. + * Animation runs at 80ms intervals independently of polling frequency. * * @param options - Polling configuration * @returns Final autofix state @@ -165,29 +158,40 @@ export async function pollAutofixState( const startTime = Date.now(); let tick = 0; + let currentMessage = "Waiting for analysis to start..."; + + // Animation timer runs independently of polling for smooth spinner + let animationTimer: Timer | undefined; + if (!json) { + animationTimer = setInterval(() => { + const display = truncateProgressMessage(currentMessage); + stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`); + tick += 1; + }, ANIMATION_INTERVAL_MS); + } - while (Date.now() - startTime < timeoutMs) { - const state = await getAutofixState(orgSlug, issueId); + try { + while (Date.now() - startTime < timeoutMs) { + const state = await getAutofixState(orgSlug, issueId); - if (!state) { - await Bun.sleep(pollIntervalMs); - continue; - } - - if (!json) { - updateProgressDisplay(stderr, state, tick); - tick += 1; - } + if (state) { + // Update message for animation loop to display + currentMessage = getProgressMessage(state); - if (shouldStopPolling(state, stopOnWaitingForUser)) { - if (!json) { - stderr.write("\n"); + if (shouldStopPolling(state, stopOnWaitingForUser)) { + return state; + } } - return state; + + await Bun.sleep(pollIntervalMs); } - await Bun.sleep(pollIntervalMs); + throw new Error(timeoutMessage); + } finally { + // Clean up animation timer + if (animationTimer) { + clearInterval(animationTimer); + stderr.write("\n"); + } } - - throw new Error(timeoutMessage); } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0edf11f9f..16de0dcf6 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -7,14 +7,10 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; -import { - type AutofixResponse, - AutofixResponseSchema, - type AutofixState, - type AutofixTriggerResponse, - AutofixTriggerResponseSchema, - type AutofixUpdatePayload, - type StoppingPoint, +import type { + AutofixResponse, + AutofixState, + AutofixUpdatePayload, } from "../types/autofix.js"; import { type IssueSummary, @@ -417,48 +413,22 @@ export function updateIssueStatus( // Autofix (Seer) API Methods // ───────────────────────────────────────────────────────────────────────────── -type TriggerAutofixOptions = { - /** Where to stop the autofix process */ - stoppingPoint?: StoppingPoint; - /** Specific event ID to analyze (uses recommended event if not provided) */ - eventId?: string; - /** Custom instruction to guide the autofix process */ - instruction?: string; -}; - /** * Trigger an autofix run for an issue. * + * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID - * @param options - Options for the autofix run * @returns The run_id for polling status * @throws {ApiError} On API errors (402 = no budget, 403 = not enabled) */ export function triggerAutofix( orgSlug: string, - issueId: string, - options: TriggerAutofixOptions = {} -): Promise { - const body: Record = {}; - - if (options.stoppingPoint) { - body.stoppingPoint = options.stoppingPoint; - } - if (options.eventId) { - body.eventId = options.eventId; - } - if (options.instruction) { - body.instruction = options.instruction; - } - - return apiRequest( - `organizations/${orgSlug}/issues/${issueId}/autofix/`, - { - method: "POST", - body, - schema: AutofixTriggerResponseSchema, - } - ); + issueId: string +): Promise { + return apiRequest(`organizations/${orgSlug}/issues/${issueId}/autofix/`, { + method: "POST", + body: { step: "root_cause" }, + }); } /** @@ -472,11 +442,9 @@ export async function getAutofixState( issueId: string ): Promise { const response = await apiRequest( - `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - { - schema: AutofixResponseSchema, - } + `/organizations/${orgSlug}/issues/${issueId}/autofix/` ); + return response.autofix; } diff --git a/src/lib/formatters/autofix.ts b/src/lib/formatters/autofix.ts index 3dc8d0815..bf9ed9f45 100644 --- a/src/lib/formatters/autofix.ts +++ b/src/lib/formatters/autofix.ts @@ -4,13 +4,17 @@ * Formatting utilities for Seer Autofix command output. */ +import chalk from "chalk"; import type { AutofixState, AutofixStep, RootCause, + RootCauseArtifact, } from "../../types/autofix.js"; import { cyan, green, muted, yellow } from "./colors.js"; +const bold = (text: string): string => chalk.bold(text); + // ───────────────────────────────────────────────────────────────────────────── // Spinner Frames // ───────────────────────────────────────────────────────────────────────────── @@ -30,6 +34,22 @@ export function getSpinnerFrame(tick: number): string { // Progress Formatting // ───────────────────────────────────────────────────────────────────────────── +/** Maximum length for progress messages to fit in a single terminal line */ +const MAX_PROGRESS_LENGTH = 300; + +/** + * Truncate a progress message to fit in a single terminal line. + * + * @param message - Progress message to truncate + * @returns Truncated message with ellipsis if needed + */ +export function truncateProgressMessage(message: string): string { + if (message.length <= MAX_PROGRESS_LENGTH) { + return message; + } + return `${message.slice(0, MAX_PROGRESS_LENGTH - 3)}...`; +} + /** * Format a progress message with spinner. * @@ -193,16 +213,7 @@ export function formatRootCauseList( // Add hint for next steps lines.push(""); - if (causes.length === 1) { - lines.push( - muted(`To create a fix, run: sentry issue fix ${issueId} --cause 0`) - ); - } else { - lines.push( - muted(`To create a fix, run: sentry issue fix ${issueId} --cause `) - ); - lines.push(muted(` where is 0-${causes.length - 1}`)); - } + lines.push(muted(`To create a fix, run: sentry issue fix ${issueId}`)); return lines; } @@ -351,3 +362,76 @@ export function formatAutofixError(status: number, detail?: string): string { return detail ?? "An error occurred with the autofix request."; } } + +// ───────────────────────────────────────────────────────────────────────────── +// Explorer Mode Root Cause Formatting +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format the root cause analysis header. + */ +export function formatRootCauseAnalysisHeader(): string[] { + return [bold("Root Cause Analysis"), "═".repeat(60)]; +} + +/** + * Format a root cause artifact from explorer mode for human-readable display. + * + * Output format: + * Root Cause Analysis + * ════════════════════════════════════════════════════════════ + * + * Summary: + * {one_line_description} + * + * Why This Happened: + * 1. {five_whys[0]} + * 2. {five_whys[1]} + * ... + * + * Steps to Reproduce: + * 1. {reproduction_steps[0]} + * 2. {reproduction_steps[1]} + * ... + * + * @param artifact - Root cause artifact from explorer mode + * @returns Array of formatted lines + */ +export function formatRootCauseArtifact(artifact: RootCauseArtifact): string[] { + const lines: string[] = []; + + // Header + lines.push(""); + lines.push(...formatRootCauseAnalysisHeader()); + lines.push(""); + + // Summary + lines.push(yellow("Summary:")); + lines.push(` ${artifact.data.one_line_description}`); + lines.push(""); + + // Five Whys + if (artifact.data.five_whys.length > 0) { + lines.push(cyan("Why This Happened:")); + for (let i = 0; i < artifact.data.five_whys.length; i++) { + const why = artifact.data.five_whys[i]; + if (why) { + lines.push(` ${i + 1}. ${why}`); + } + } + lines.push(""); + } + + // Reproduction Steps + if (artifact.data.reproduction_steps.length > 0) { + lines.push(green("Steps to Reproduce:")); + for (let i = 0; i < artifact.data.reproduction_steps.length; i++) { + const step = artifact.data.reproduction_steps[i]; + if (step) { + lines.push(` ${i + 1}. ${step}`); + } + } + } + + return lines; +} diff --git a/src/types/autofix.ts b/src/types/autofix.ts index 5e10d1bd9..b00b40170 100644 --- a/src/types/autofix.ts +++ b/src/types/autofix.ts @@ -316,3 +316,132 @@ export function extractPrUrl(state: AutofixState): string | undefined { return; } + +// ───────────────────────────────────────────────────────────────────────────── +// Explorer Mode Types (for root cause analysis via ?mode=explorer) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Root cause data from explorer mode analysis. + * Contains detailed breakdown of why the issue occurred. + */ +export const RootCauseDataSchema = z.object({ + /** One-line summary of the root cause */ + one_line_description: z.string(), + /** Progressive "five whys" analysis */ + five_whys: z.array(z.string()), + /** Steps to reproduce the issue */ + reproduction_steps: z.array(z.string()), +}); + +export type RootCauseData = z.infer; + +/** + * Root cause artifact from explorer mode. + * Wrapper around the root cause data with metadata. + */ +export const RootCauseArtifactSchema = z.object({ + /** Artifact key - always "root_cause" for this type */ + key: z.literal("root_cause"), + /** The root cause analysis data */ + data: RootCauseDataSchema, + /** Optional reason for this artifact */ + reason: z.string().optional(), +}); + +export type RootCauseArtifact = z.infer; + +/** + * Generic artifact schema for explorer mode blocks. + * Artifacts can be various types (root_cause, code_snippet, etc.) + */ +export const AutofixArtifactSchema = z + .object({ + key: z.string(), + data: z.unknown(), + reason: z.string().optional(), + }) + .passthrough(); + +export type AutofixArtifact = z.infer; + +/** + * Block structure for explorer mode responses. + * Each block represents a step or message in the analysis. + */ +export const AutofixBlockSchema = z + .object({ + id: z.string(), + message: z + .object({ + role: z.string(), + content: z.string().nullable(), + }) + .passthrough(), + timestamp: z.string(), + artifacts: z.array(AutofixArtifactSchema).optional(), + }) + .passthrough(); + +export type AutofixBlock = z.infer; + +/** + * Explorer mode autofix state. + * Used when querying with ?mode=explorer for root cause analysis. + */ +export const AutofixExplorerStateSchema = z + .object({ + run_id: z.number(), + status: z.string(), + blocks: z.array(AutofixBlockSchema).optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + updated_at: z.string().optional(), + }) + .passthrough(); + +export type AutofixExplorerState = z.infer; + +/** + * Response from GET /organizations/{org}/issues/{id}/autofix/?mode=explorer + */ +export const AutofixExplorerResponseSchema = z.object({ + autofix: AutofixExplorerStateSchema.nullable(), +}); + +export type AutofixExplorerResponse = z.infer< + typeof AutofixExplorerResponseSchema +>; + +/** + * Extract root cause artifact from explorer mode state. + * Searches through all blocks for the root_cause artifact. + * + * @param state - Explorer mode autofix state + * @returns RootCauseArtifact if found, null otherwise + */ +export function extractRootCauseArtifact( + state: AutofixExplorerState +): RootCauseArtifact | null { + if (!state.blocks) { + return null; + } + + for (const block of state.blocks) { + if (!block.artifacts) { + continue; + } + + for (const artifact of block.artifacts) { + if (artifact.key === "root_cause") { + // Validate the artifact matches our expected schema + const result = RootCauseArtifactSchema.safeParse(artifact); + if (result.success) { + return result.data; + } + } + } + } + + return null; +} diff --git a/src/types/index.ts b/src/types/index.ts index 7af804f71..da2d61734 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,10 @@ export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; // Autofix types export type { + AutofixArtifact, + AutofixBlock, + AutofixExplorerResponse, + AutofixExplorerState, AutofixResponse, AutofixState, AutofixStatus, @@ -16,18 +20,27 @@ export type { AutofixTriggerResponse, AutofixUpdatePayload, RootCause, + RootCauseArtifact, + RootCauseData, StoppingPoint, } from "./autofix.js"; export { AUTOFIX_STATUSES, + AutofixArtifactSchema, + AutofixBlockSchema, + AutofixExplorerResponseSchema, + AutofixExplorerStateSchema, AutofixResponseSchema, AutofixStateSchema, AutofixStepSchema, AutofixTriggerResponseSchema, extractPrUrl, + extractRootCauseArtifact, extractRootCauses, getLatestProgress, isTerminalStatus, + RootCauseArtifactSchema, + RootCauseDataSchema, RootCauseSchema, STOPPING_POINTS, TERMINAL_STATUSES, diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 39914f48a..275cffa76 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -335,27 +335,48 @@ describe("pollAutofixState", () => { test("writes progress to stderr when not in JSON mode", async () => { let stderrOutput = ""; + let fetchCount = 0; - globalThis.fetch = async () => - new Response( + // Return PROCESSING first to allow animation interval to fire, + // then COMPLETED on second call + globalThis.fetch = async () => { + fetchCount += 1; + + if (fetchCount === 1) { + return new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "PROCESSING", + steps: [ + { + id: "step-1", + key: "analysis", + status: "PROCESSING", + title: "Analysis", + progress: [ + { + message: "Analyzing...", + timestamp: "2025-01-01T00:00:00Z", + }, + ], + }, + ], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response( JSON.stringify({ autofix: { run_id: 12_345, status: "COMPLETED", - steps: [ - { - id: "step-1", - key: "analysis", - status: "COMPLETED", - title: "Analysis", - progress: [ - { - message: "Analyzing...", - timestamp: "2025-01-01T00:00:00Z", - }, - ], - }, - ], + steps: [], }, }), { @@ -363,6 +384,7 @@ describe("pollAutofixState", () => { headers: { "Content-Type": "application/json" }, } ); + }; const stderrMock = { write: (s: string) => { @@ -375,6 +397,7 @@ describe("pollAutofixState", () => { issueId: "123456789", stderr: stderrMock, json: false, + pollIntervalMs: 100, // Allow animation interval (80ms) to fire }); expect(stderrOutput).toContain("Analyzing"); diff --git a/test/lib/api-client.autofix.test.ts b/test/lib/api-client.autofix.test.ts index 01f4f7278..e5b75f6cc 100644 --- a/test/lib/api-client.autofix.test.ts +++ b/test/lib/api-client.autofix.test.ts @@ -8,9 +8,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { + getAutofixExplorerState, getAutofixState, getIssueSummary, triggerAutofix, + triggerAutofixAnalysis, updateAutofix, } from "../../src/lib/api-client.js"; import { setAuthToken } from "../../src/lib/config.js"; @@ -379,3 +381,154 @@ describe("getIssueSummary", () => { await expect(getIssueSummary("test-org", "123456789")).rejects.toThrow(); }); }); + +describe("getAutofixExplorerState", () => { + test("sends GET request with mode=explorer parameter", async () => { + let capturedRequest: Request | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + + return new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Analysis complete" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ + { + key: "root_cause", + data: { + one_line_description: "Test root cause", + five_whys: ["Why 1", "Why 2"], + reproduction_steps: ["Step 1"], + }, + }, + ], + }, + ], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const result = await getAutofixExplorerState("test-org", "123456789"); + + expect(result?.run_id).toBe(12_345); + expect(result?.status).toBe("COMPLETED"); + expect(result?.blocks).toHaveLength(1); + expect(capturedRequest?.method).toBe("GET"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); + expect(capturedRequest?.url).toContain("mode=explorer"); + }); + + test("returns null when autofix is null", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ autofix: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + const result = await getAutofixExplorerState("test-org", "123456789"); + expect(result).toBeNull(); + }); + + test("returns state with blocks and artifacts", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Found root cause" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ + { + key: "root_cause", + data: { + one_line_description: "Memory leak in connection pool", + five_whys: [ + "Connections not released", + "Missing finally block", + ], + reproduction_steps: ["Open connection", "Don't close it"], + }, + }, + ], + }, + ], + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixExplorerState("test-org", "123456789"); + expect(result?.blocks?.[0]?.artifacts?.[0]?.key).toBe("root_cause"); + }); +}); + +describe("triggerAutofixAnalysis", () => { + test("sends POST request to autofix endpoint", async () => { + let capturedRequest: Request | undefined; + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + capturedBody = await new Request(input, init).json(); + + return new Response(JSON.stringify({ run_id: 12_345 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await triggerAutofixAnalysis("test-org", "123456789"); + + expect(result.run_id).toBe(12_345); + expect(capturedRequest?.method).toBe("POST"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); + expect(capturedBody).toEqual({}); + }); + + test("throws ApiError on 402 response (no budget)", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "No budget for Seer Autofix" }), { + status: 402, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + triggerAutofixAnalysis("test-org", "123456789") + ).rejects.toThrow(); + }); + + test("throws ApiError on 403 response (not enabled)", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "AI Autofix is not enabled" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + triggerAutofixAnalysis("test-org", "123456789") + ).rejects.toThrow(); + }); +}); diff --git a/test/lib/formatters/autofix.test.ts b/test/lib/formatters/autofix.test.ts index 48a94aba8..e21eaa219 100644 --- a/test/lib/formatters/autofix.test.ts +++ b/test/lib/formatters/autofix.test.ts @@ -12,12 +12,19 @@ import { formatProgressLine, formatPrResult, formatRootCause, + formatRootCauseAnalysisHeader, + formatRootCauseArtifact, formatRootCauseHeader, formatRootCauseList, getProgressMessage, getSpinnerFrame, + truncateProgressMessage, } from "../../../src/lib/formatters/autofix.js"; -import type { AutofixState, RootCause } from "../../../src/types/autofix.js"; +import type { + AutofixState, + RootCause, + RootCauseArtifact, +} from "../../../src/types/autofix.js"; describe("getSpinnerFrame", () => { test("returns a spinner character", () => { @@ -52,6 +59,31 @@ describe("formatProgressLine", () => { }); }); +describe("truncateProgressMessage", () => { + test("returns short message unchanged", () => { + const message = "Analyzing issue..."; + expect(truncateProgressMessage(message)).toBe(message); + }); + + test("returns message at exactly max length unchanged", () => { + const message = "x".repeat(300); + expect(truncateProgressMessage(message)).toBe(message); + }); + + test("truncates message exceeding max length", () => { + const message = "x".repeat(350); + const result = truncateProgressMessage(message); + expect(result.length).toBe(300); + expect(result.endsWith("...")).toBe(true); + }); + + test("preserves content before truncation point", () => { + const message = `Important prefix ${"x".repeat(350)}`; + const result = truncateProgressMessage(message); + expect(result.startsWith("Important prefix")).toBe(true); + }); +}); + describe("getProgressMessage", () => { test("returns progress message from steps", () => { const state: AutofixState = { @@ -172,10 +204,9 @@ describe("formatRootCauseList", () => { const output = lines.join("\n"); expect(output).toContain("Single root cause"); expect(output).toContain("sentry issue fix ISSUE-123"); - expect(output).toContain("--cause 0"); }); - test("formats multiple causes with selection hint", () => { + test("formats multiple causes with fix hint", () => { const causes: RootCause[] = [ { id: 0, description: "First cause" }, { id: 1, description: "Second cause" }, @@ -185,7 +216,7 @@ describe("formatRootCauseList", () => { const output = lines.join("\n"); expect(output).toContain("First cause"); expect(output).toContain("Second cause"); - expect(output).toContain("sentry issue fix ISSUE-456 --cause "); + expect(output).toContain("sentry issue fix ISSUE-456"); }); test("handles empty causes array", () => { @@ -266,3 +297,131 @@ describe("formatAutofixError", () => { expect(message).toBeTruthy(); }); }); + +describe("formatRootCauseAnalysisHeader", () => { + test("returns array with header and separator", () => { + const lines = formatRootCauseAnalysisHeader(); + expect(Array.isArray(lines)).toBe(true); + expect(lines.length).toBe(2); + expect(lines.join("\n")).toContain("Root Cause Analysis"); + }); +}); + +describe("formatRootCauseArtifact", () => { + test("formats complete root cause artifact", () => { + const artifact: RootCauseArtifact = { + key: "root_cause", + data: { + one_line_description: "Database connection pool exhausted", + five_whys: [ + "Connection pool ran out of connections", + "Connections were not being released", + "Missing finally block in database calls", + "Code review didn't catch the issue", + "No automated tests for connection cleanup", + ], + reproduction_steps: [ + "Start the application", + "Make 100 concurrent database requests", + "Observe connection timeout errors", + ], + }, + }; + + const lines = formatRootCauseArtifact(artifact); + const output = lines.join("\n"); + + // Check header + expect(output).toContain("Root Cause Analysis"); + + // Check summary + expect(output).toContain("Summary:"); + expect(output).toContain("Database connection pool exhausted"); + + // Check five whys + expect(output).toContain("Why This Happened:"); + expect(output).toContain("1. Connection pool ran out"); + expect(output).toContain("2. Connections were not being released"); + expect(output).toContain("5. No automated tests"); + + // Check reproduction steps + expect(output).toContain("Steps to Reproduce:"); + expect(output).toContain("1. Start the application"); + expect(output).toContain("3. Observe connection timeout"); + }); + + test("formats artifact with minimal data", () => { + const artifact: RootCauseArtifact = { + key: "root_cause", + data: { + one_line_description: "Simple error", + five_whys: [], + reproduction_steps: [], + }, + }; + + const lines = formatRootCauseArtifact(artifact); + const output = lines.join("\n"); + + expect(output).toContain("Root Cause Analysis"); + expect(output).toContain("Summary:"); + expect(output).toContain("Simple error"); + // Should not have numbered lists when arrays are empty + expect(output).not.toContain("1."); + }); + + test("formats artifact with only five_whys", () => { + const artifact: RootCauseArtifact = { + key: "root_cause", + data: { + one_line_description: "Error with whys only", + five_whys: ["First why", "Second why"], + reproduction_steps: [], + }, + }; + + const lines = formatRootCauseArtifact(artifact); + const output = lines.join("\n"); + + expect(output).toContain("Why This Happened:"); + expect(output).toContain("1. First why"); + expect(output).toContain("2. Second why"); + expect(output).not.toContain("Steps to Reproduce:"); + }); + + test("formats artifact with only reproduction_steps", () => { + const artifact: RootCauseArtifact = { + key: "root_cause", + data: { + one_line_description: "Error with steps only", + five_whys: [], + reproduction_steps: ["Step one", "Step two", "Step three"], + }, + }; + + const lines = formatRootCauseArtifact(artifact); + const output = lines.join("\n"); + + expect(output).not.toContain("Why This Happened:"); + expect(output).toContain("Steps to Reproduce:"); + expect(output).toContain("1. Step one"); + expect(output).toContain("3. Step three"); + }); + + test("includes reason field if present", () => { + const artifact: RootCauseArtifact = { + key: "root_cause", + data: { + one_line_description: "Test error", + five_whys: ["Why"], + reproduction_steps: ["Step"], + }, + reason: "Based on stack trace analysis", + }; + + const lines = formatRootCauseArtifact(artifact); + // The reason field is optional and may or may not be displayed + // Just verify the artifact formats without error + expect(lines.length).toBeGreaterThan(0); + }); +}); diff --git a/test/types/autofix.test.ts b/test/types/autofix.test.ts index dc12b9d35..1fc4c835d 100644 --- a/test/types/autofix.test.ts +++ b/test/types/autofix.test.ts @@ -6,9 +6,11 @@ import { describe, expect, test } from "bun:test"; import { + type AutofixExplorerState, type AutofixState, type AutofixStep, extractPrUrl, + extractRootCauseArtifact, extractRootCauses, getLatestProgress, isTerminalStatus, @@ -303,3 +305,136 @@ describe("extractPrUrl", () => { expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/789"); }); }); + +describe("extractRootCauseArtifact", () => { + test("extracts root_cause artifact from blocks", () => { + const state: AutofixExplorerState = { + run_id: 123, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Analyzing..." }, + timestamp: "2025-01-01T00:00:00Z", + }, + { + id: "block-2", + message: { role: "assistant", content: "Found root cause" }, + timestamp: "2025-01-01T00:01:00Z", + artifacts: [ + { + key: "root_cause", + data: { + one_line_description: "Database connection timeout", + five_whys: [ + "Connection pool exhausted", + "Too many concurrent requests", + "Missing connection limits", + ], + reproduction_steps: [ + "Start 100 concurrent requests", + "Wait for pool exhaustion", + "Observe timeout", + ], + }, + }, + ], + }, + ], + }; + + const artifact = extractRootCauseArtifact(state); + expect(artifact).not.toBeNull(); + expect(artifact?.key).toBe("root_cause"); + expect(artifact?.data.one_line_description).toBe( + "Database connection timeout" + ); + expect(artifact?.data.five_whys).toHaveLength(3); + expect(artifact?.data.reproduction_steps).toHaveLength(3); + }); + + test("returns null when no blocks", () => { + const state: AutofixExplorerState = { + run_id: 123, + status: "COMPLETED", + }; + + expect(extractRootCauseArtifact(state)).toBeNull(); + }); + + test("returns null when blocks have no artifacts", () => { + const state: AutofixExplorerState = { + run_id: 123, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Processing" }, + timestamp: "2025-01-01T00:00:00Z", + }, + ], + }; + + expect(extractRootCauseArtifact(state)).toBeNull(); + }); + + test("returns null when no root_cause artifact exists", () => { + const state: AutofixExplorerState = { + run_id: 123, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Found code" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ + { + key: "code_snippet", + data: { code: "const x = 1;" }, + }, + ], + }, + ], + }; + + expect(extractRootCauseArtifact(state)).toBeNull(); + }); + + test("finds root_cause in later blocks", () => { + const state: AutofixExplorerState = { + run_id: 123, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Starting" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [{ key: "other", data: {} }], + }, + { + id: "block-2", + message: { role: "assistant", content: "More analysis" }, + timestamp: "2025-01-01T00:01:00Z", + }, + { + id: "block-3", + message: { role: "assistant", content: "Found it" }, + timestamp: "2025-01-01T00:02:00Z", + artifacts: [ + { + key: "root_cause", + data: { + one_line_description: "Memory leak in loop", + five_whys: ["Objects not released"], + reproduction_steps: ["Run loop 1000 times"], + }, + }, + ], + }, + ], + }; + + const artifact = extractRootCauseArtifact(state); + expect(artifact?.data.one_line_description).toBe("Memory leak in loop"); + }); +}); From 764883d9cb6ffdd98fcbc38c1ee7a69c3a0ab8b3 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 03:06:54 +0530 Subject: [PATCH 06/19] chore: minor changes --- src/commands/issue/fix.ts | 26 +++--- src/lib/api-client.ts | 15 ++-- src/lib/formatters/autofix.ts | 57 ++++++++++++ src/types/autofix.ts | 160 ++++++++++++++++++++++++++++------ src/types/index.ts | 7 ++ 5 files changed, 214 insertions(+), 51 deletions(-) diff --git a/src/commands/issue/fix.ts b/src/commands/issue/fix.ts index c95274db8..8d4974b1b 100644 --- a/src/commands/issue/fix.ts +++ b/src/commands/issue/fix.ts @@ -11,15 +11,14 @@ import { getAutofixState, updateAutofix } from "../../lib/api-client.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; import { formatAutofixError, - formatPrNotFound, - formatPrResult, + formatSolution, } from "../../lib/formatters/autofix.js"; import { muted } from "../../lib/formatters/colors.js"; import { writeJson } from "../../lib/formatters/index.js"; import { type AutofixState, - extractPrUrl, extractRootCauses, + extractSolution, type RootCause, } from "../../types/autofix.js"; import { pollAutofixState, resolveOrgAndIssueId } from "./utils.js"; @@ -200,11 +199,7 @@ export const fixCommand = buildCommand({ } // Update autofix to continue to PR creation - await updateAutofix(numericId, state.run_id, { - type: "select_root_cause", - cause_id: causeId, - stopping_point: "open_pr", - }); + await updateAutofix(org, numericId, state.run_id); // Poll until PR is created const finalState = await pollAutofixState({ @@ -227,26 +222,27 @@ export const fixCommand = buildCommand({ throw new Error("Fix creation was cancelled."); } - // Try to extract PR URL - const prUrl = extractPrUrl(finalState); + // Extract solution artifact + const solution = extractSolution(finalState); // Output results if (flags.json) { writeJson(stdout, { run_id: finalState.run_id, status: finalState.status, - pr_url: prUrl ?? null, + solution: solution?.data ?? null, }); return; } // Human-readable output - if (prUrl) { - const lines = formatPrResult(prUrl); + if (solution) { + const lines = formatSolution(solution); stdout.write(`${lines.join("\n")}\n`); } else { - const lines = formatPrNotFound(); - stdout.write(`${lines.join("\n")}\n`); + stderr.write( + "No solution found. Check the Sentry web UI for details.\n" + ); } } catch (error) { // Handle API errors with friendly messages diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 16de0dcf6..410deb8aa 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -7,11 +7,7 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; -import type { - AutofixResponse, - AutofixState, - AutofixUpdatePayload, -} from "../types/autofix.js"; +import type { AutofixResponse, AutofixState } from "../types/autofix.js"; import { type IssueSummary, IssueSummarySchema, @@ -451,21 +447,22 @@ export async function getAutofixState( /** * Update an autofix run (e.g., select root cause, continue to PR). * + * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID * @param runId - The autofix run ID * @param payload - The update payload (select_root_cause, select_solution, create_pr) * @returns The response from the API */ export function updateAutofix( + orgSlug: string, issueId: string, - runId: number, - payload: AutofixUpdatePayload + runId: number ): Promise { - return apiRequest(`/issues/${issueId}/autofix/update/`, { + return apiRequest(`/organizations/${orgSlug}/issues/${issueId}/autofix/`, { method: "POST", body: { run_id: runId, - payload, + step: "solution", }, }); } diff --git a/src/lib/formatters/autofix.ts b/src/lib/formatters/autofix.ts index bf9ed9f45..ce688a54e 100644 --- a/src/lib/formatters/autofix.ts +++ b/src/lib/formatters/autofix.ts @@ -10,6 +10,7 @@ import type { AutofixStep, RootCause, RootCauseArtifact, + SolutionArtifact, } from "../../types/autofix.js"; import { cyan, green, muted, yellow } from "./colors.js"; @@ -435,3 +436,59 @@ export function formatRootCauseArtifact(artifact: RootCauseArtifact): string[] { return lines; } + +// ───────────────────────────────────────────────────────────────────────────── +// Solution Formatting +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Format a solution artifact for human-readable display. + * + * Output format: + * Solution + * ════════════════════════════════════════════════════════════ + * + * Summary: + * {one_line_summary} + * + * Steps to implement: + * 1. {title} + * {description} + * + * 2. {title} + * {description} + * ... + * + * @param solution - Solution artifact from autofix + * @returns Array of formatted lines + */ +export function formatSolution(solution: SolutionArtifact): string[] { + const lines: string[] = []; + + // Header + lines.push(""); + lines.push(bold("Solution")); + lines.push("═".repeat(60)); + lines.push(""); + + // Summary + lines.push(yellow("Summary:")); + lines.push(` ${solution.data.one_line_summary}`); + lines.push(""); + + // Steps to implement + if (solution.data.steps.length > 0) { + lines.push(cyan("Steps to implement:")); + lines.push(""); + for (let i = 0; i < solution.data.steps.length; i++) { + const step = solution.data.steps[i]; + if (step) { + lines.push(` ${i + 1}. ${bold(step.title)}`); + lines.push(` ${muted(step.description)}`); + lines.push(""); + } + } + } + + return lines; +} diff --git a/src/types/autofix.ts b/src/types/autofix.ts index b00b40170..bd5e2fdb8 100644 --- a/src/types/autofix.ts +++ b/src/types/autofix.ts @@ -117,15 +117,17 @@ export type RootCauseSelection = z.infer; // Autofix Step // ───────────────────────────────────────────────────────────────────────────── -export const AutofixStepSchema = z.object({ - id: z.string(), - key: z.string(), - status: z.string(), - title: z.string(), - progress: z.array(ProgressMessageSchema).optional(), - causes: z.array(RootCauseSchema).optional(), - selection: RootCauseSelectionSchema.optional(), -}); +export const AutofixStepSchema = z + .object({ + id: z.string(), + key: z.string(), + status: z.string(), + title: z.string(), + progress: z.array(ProgressMessageSchema).optional(), + causes: z.array(RootCauseSchema).optional(), + selection: RootCauseSelectionSchema.optional(), + }) + .passthrough(); // Allow additional fields like artifacts export type AutofixStep = z.infer; @@ -172,28 +174,59 @@ export const PullRequestInfoSchema = z.object({ export type PullRequestInfo = z.infer; // ───────────────────────────────────────────────────────────────────────────── -// Autofix State +// Solution Artifact (from fix command) // ───────────────────────────────────────────────────────────────────────────── -export const AutofixStateSchema = z.object({ - run_id: z.number(), - status: z.string(), - updated_at: z.string().optional(), - request: z - .object({ - organization_id: z.number().optional(), - project_id: z.number().optional(), - repos: z.array(z.unknown()).optional(), - }) - .optional(), - codebases: z.record(z.string(), CodebaseInfoSchema).optional(), - steps: z.array(AutofixStepSchema).optional(), - repositories: z.array(RepositoryInfoSchema).optional(), - coding_agents: z.record(z.string(), z.unknown()).optional(), - created_at: z.string().optional(), - completed_at: z.string().optional(), +/** A single step in the solution plan */ +export const SolutionStepSchema = z.object({ + title: z.string(), + description: z.string(), +}); + +export type SolutionStep = z.infer; + +/** Solution data containing the plan to fix the issue */ +export const SolutionDataSchema = z.object({ + one_line_summary: z.string(), + steps: z.array(SolutionStepSchema), +}); + +export type SolutionData = z.infer; + +/** Solution artifact from the autofix response */ +export const SolutionArtifactSchema = z.object({ + key: z.literal("solution"), + data: SolutionDataSchema, + reason: z.string().optional(), }); +export type SolutionArtifact = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Autofix State +// ───────────────────────────────────────────────────────────────────────────── + +export const AutofixStateSchema = z + .object({ + run_id: z.number(), + status: z.string(), + updated_at: z.string().optional(), + request: z + .object({ + organization_id: z.number().optional(), + project_id: z.number().optional(), + repos: z.array(z.unknown()).optional(), + }) + .optional(), + codebases: z.record(z.string(), CodebaseInfoSchema).optional(), + steps: z.array(AutofixStepSchema).optional(), + repositories: z.array(RepositoryInfoSchema).optional(), + coding_agents: z.record(z.string(), z.unknown()).optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + }) + .passthrough(); // Allow additional fields like blocks + export type AutofixState = z.infer; // ───────────────────────────────────────────────────────────────────────────── @@ -317,6 +350,79 @@ export function extractPrUrl(state: AutofixState): string | undefined { return; } +/** Artifact structure used in blocks and steps */ +type ArtifactEntry = { key: string; data: unknown; reason?: string }; + +/** Structure that may contain artifacts */ +type WithArtifacts = { artifacts?: ArtifactEntry[] }; + +/** + * Search artifacts array for a solution artifact. + */ +function findSolutionInArtifacts( + artifacts: ArtifactEntry[] +): SolutionArtifact | null { + for (const artifact of artifacts) { + if (artifact.key === "solution") { + const result = SolutionArtifactSchema.safeParse(artifact); + if (result.success) { + return result.data; + } + } + } + return null; +} + +/** + * Search an array of containers (blocks or steps) for a solution artifact. + */ +function searchContainersForSolution( + containers: WithArtifacts[] +): SolutionArtifact | null { + for (const container of containers) { + if (container.artifacts) { + const solution = findSolutionInArtifacts(container.artifacts); + if (solution) { + return solution; + } + } + } + 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) + * @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[]; + steps?: WithArtifacts[]; + }; + + // Search in blocks first (explorer mode / newer API) + if (stateWithExtras.blocks) { + const solution = searchContainersForSolution(stateWithExtras.blocks); + if (solution) { + return solution; + } + } + + // Search in steps (regular autofix API) + if (stateWithExtras.steps) { + const solution = searchContainersForSolution(stateWithExtras.steps); + if (solution) { + return solution; + } + } + + return null; +} + // ───────────────────────────────────────────────────────────────────────────── // Explorer Mode Types (for root cause analysis via ?mode=explorer) // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/types/index.ts b/src/types/index.ts index da2d61734..cdc764dac 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,9 @@ export type { RootCause, RootCauseArtifact, RootCauseData, + SolutionArtifact, + SolutionData, + SolutionStep, StoppingPoint, } from "./autofix.js"; export { @@ -37,11 +40,15 @@ export { extractPrUrl, extractRootCauseArtifact, extractRootCauses, + extractSolution, getLatestProgress, isTerminalStatus, RootCauseArtifactSchema, RootCauseDataSchema, RootCauseSchema, + SolutionArtifactSchema, + SolutionDataSchema, + SolutionStepSchema, STOPPING_POINTS, TERMINAL_STATUSES, } from "./autofix.js"; From 7360fbf2fa63bcc03e3ebd48a62019670f4ce9eb Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 13:16:10 +0530 Subject: [PATCH 07/19] chore: minor fixes --- src/lib/api-client.ts | 23 ++- src/lib/formatters/autofix.ts | 74 -------- src/types/autofix.ts | 129 -------------- src/types/index.ts | 13 -- test/lib/api-client.autofix.test.ts | 256 ++-------------------------- test/lib/formatters/autofix.test.ts | 136 +-------------- test/types/autofix.test.ts | 135 --------------- 7 files changed, 27 insertions(+), 739 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 410deb8aa..f19475899 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -7,7 +7,12 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; -import type { AutofixResponse, AutofixState } from "../types/autofix.js"; +import { + type AutofixResponse, + type AutofixState, + type AutofixTriggerResponse, + AutofixTriggerResponseSchema, +} from "../types/autofix.js"; import { type IssueSummary, IssueSummarySchema, @@ -414,17 +419,21 @@ export function updateIssueStatus( * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID - * @returns The run_id for polling status + * @returns The trigger response with run_id * @throws {ApiError} On API errors (402 = no budget, 403 = not enabled) */ export function triggerAutofix( orgSlug: string, issueId: string -): Promise { - return apiRequest(`organizations/${orgSlug}/issues/${issueId}/autofix/`, { - method: "POST", - body: { step: "root_cause" }, - }); +): Promise { + return apiRequest( + `/organizations/${orgSlug}/issues/${issueId}/autofix/`, + { + method: "POST", + body: { step: "root_cause" }, + schema: AutofixTriggerResponseSchema, + } + ); } /** diff --git a/src/lib/formatters/autofix.ts b/src/lib/formatters/autofix.ts index ce688a54e..f6b5012b9 100644 --- a/src/lib/formatters/autofix.ts +++ b/src/lib/formatters/autofix.ts @@ -9,7 +9,6 @@ import type { AutofixState, AutofixStep, RootCause, - RootCauseArtifact, SolutionArtifact, } from "../../types/autofix.js"; import { cyan, green, muted, yellow } from "./colors.js"; @@ -364,79 +363,6 @@ export function formatAutofixError(status: number, detail?: string): string { } } -// ───────────────────────────────────────────────────────────────────────────── -// Explorer Mode Root Cause Formatting -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Format the root cause analysis header. - */ -export function formatRootCauseAnalysisHeader(): string[] { - return [bold("Root Cause Analysis"), "═".repeat(60)]; -} - -/** - * Format a root cause artifact from explorer mode for human-readable display. - * - * Output format: - * Root Cause Analysis - * ════════════════════════════════════════════════════════════ - * - * Summary: - * {one_line_description} - * - * Why This Happened: - * 1. {five_whys[0]} - * 2. {five_whys[1]} - * ... - * - * Steps to Reproduce: - * 1. {reproduction_steps[0]} - * 2. {reproduction_steps[1]} - * ... - * - * @param artifact - Root cause artifact from explorer mode - * @returns Array of formatted lines - */ -export function formatRootCauseArtifact(artifact: RootCauseArtifact): string[] { - const lines: string[] = []; - - // Header - lines.push(""); - lines.push(...formatRootCauseAnalysisHeader()); - lines.push(""); - - // Summary - lines.push(yellow("Summary:")); - lines.push(` ${artifact.data.one_line_description}`); - lines.push(""); - - // Five Whys - if (artifact.data.five_whys.length > 0) { - lines.push(cyan("Why This Happened:")); - for (let i = 0; i < artifact.data.five_whys.length; i++) { - const why = artifact.data.five_whys[i]; - if (why) { - lines.push(` ${i + 1}. ${why}`); - } - } - lines.push(""); - } - - // Reproduction Steps - if (artifact.data.reproduction_steps.length > 0) { - lines.push(green("Steps to Reproduce:")); - for (let i = 0; i < artifact.data.reproduction_steps.length; i++) { - const step = artifact.data.reproduction_steps[i]; - if (step) { - lines.push(` ${i + 1}. ${step}`); - } - } - } - - return lines; -} - // ───────────────────────────────────────────────────────────────────────────── // Solution Formatting // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/types/autofix.ts b/src/types/autofix.ts index bd5e2fdb8..b085dbb77 100644 --- a/src/types/autofix.ts +++ b/src/types/autofix.ts @@ -422,132 +422,3 @@ export function extractSolution(state: AutofixState): SolutionArtifact | null { return null; } - -// ───────────────────────────────────────────────────────────────────────────── -// Explorer Mode Types (for root cause analysis via ?mode=explorer) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Root cause data from explorer mode analysis. - * Contains detailed breakdown of why the issue occurred. - */ -export const RootCauseDataSchema = z.object({ - /** One-line summary of the root cause */ - one_line_description: z.string(), - /** Progressive "five whys" analysis */ - five_whys: z.array(z.string()), - /** Steps to reproduce the issue */ - reproduction_steps: z.array(z.string()), -}); - -export type RootCauseData = z.infer; - -/** - * Root cause artifact from explorer mode. - * Wrapper around the root cause data with metadata. - */ -export const RootCauseArtifactSchema = z.object({ - /** Artifact key - always "root_cause" for this type */ - key: z.literal("root_cause"), - /** The root cause analysis data */ - data: RootCauseDataSchema, - /** Optional reason for this artifact */ - reason: z.string().optional(), -}); - -export type RootCauseArtifact = z.infer; - -/** - * Generic artifact schema for explorer mode blocks. - * Artifacts can be various types (root_cause, code_snippet, etc.) - */ -export const AutofixArtifactSchema = z - .object({ - key: z.string(), - data: z.unknown(), - reason: z.string().optional(), - }) - .passthrough(); - -export type AutofixArtifact = z.infer; - -/** - * Block structure for explorer mode responses. - * Each block represents a step or message in the analysis. - */ -export const AutofixBlockSchema = z - .object({ - id: z.string(), - message: z - .object({ - role: z.string(), - content: z.string().nullable(), - }) - .passthrough(), - timestamp: z.string(), - artifacts: z.array(AutofixArtifactSchema).optional(), - }) - .passthrough(); - -export type AutofixBlock = z.infer; - -/** - * Explorer mode autofix state. - * Used when querying with ?mode=explorer for root cause analysis. - */ -export const AutofixExplorerStateSchema = z - .object({ - run_id: z.number(), - status: z.string(), - blocks: z.array(AutofixBlockSchema).optional(), - created_at: z.string().optional(), - completed_at: z.string().optional(), - updated_at: z.string().optional(), - }) - .passthrough(); - -export type AutofixExplorerState = z.infer; - -/** - * Response from GET /organizations/{org}/issues/{id}/autofix/?mode=explorer - */ -export const AutofixExplorerResponseSchema = z.object({ - autofix: AutofixExplorerStateSchema.nullable(), -}); - -export type AutofixExplorerResponse = z.infer< - typeof AutofixExplorerResponseSchema ->; - -/** - * Extract root cause artifact from explorer mode state. - * Searches through all blocks for the root_cause artifact. - * - * @param state - Explorer mode autofix state - * @returns RootCauseArtifact if found, null otherwise - */ -export function extractRootCauseArtifact( - state: AutofixExplorerState -): RootCauseArtifact | null { - if (!state.blocks) { - return null; - } - - for (const block of state.blocks) { - if (!block.artifacts) { - continue; - } - - for (const artifact of block.artifacts) { - if (artifact.key === "root_cause") { - // Validate the artifact matches our expected schema - const result = RootCauseArtifactSchema.safeParse(artifact); - if (result.success) { - return result.data; - } - } - } - } - - return null; -} diff --git a/src/types/index.ts b/src/types/index.ts index cdc764dac..1fbd0f684 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,10 +9,6 @@ export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; // Autofix types export type { - AutofixArtifact, - AutofixBlock, - AutofixExplorerResponse, - AutofixExplorerState, AutofixResponse, AutofixState, AutofixStatus, @@ -20,8 +16,6 @@ export type { AutofixTriggerResponse, AutofixUpdatePayload, RootCause, - RootCauseArtifact, - RootCauseData, SolutionArtifact, SolutionData, SolutionStep, @@ -29,22 +23,15 @@ export type { } from "./autofix.js"; export { AUTOFIX_STATUSES, - AutofixArtifactSchema, - AutofixBlockSchema, - AutofixExplorerResponseSchema, - AutofixExplorerStateSchema, AutofixResponseSchema, AutofixStateSchema, AutofixStepSchema, AutofixTriggerResponseSchema, extractPrUrl, - extractRootCauseArtifact, extractRootCauses, extractSolution, getLatestProgress, isTerminalStatus, - RootCauseArtifactSchema, - RootCauseDataSchema, RootCauseSchema, SolutionArtifactSchema, SolutionDataSchema, diff --git a/test/lib/api-client.autofix.test.ts b/test/lib/api-client.autofix.test.ts index e5b75f6cc..49374bb07 100644 --- a/test/lib/api-client.autofix.test.ts +++ b/test/lib/api-client.autofix.test.ts @@ -8,11 +8,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { - getAutofixExplorerState, getAutofixState, getIssueSummary, triggerAutofix, - triggerAutofixAnalysis, updateAutofix, } from "../../src/lib/api-client.js"; import { setAuthToken } from "../../src/lib/config.js"; @@ -60,16 +58,15 @@ describe("triggerAutofix", () => { }); }; - const result = await triggerAutofix("test-org", "123456789"); + await triggerAutofix("test-org", "123456789"); - expect(result.run_id).toBe(12_345); expect(capturedRequest?.method).toBe("POST"); expect(capturedRequest?.url).toContain( "/organizations/test-org/issues/123456789/autofix/" ); }); - test("includes stoppingPoint in request body", async () => { + test("includes step in request body", async () => { let capturedBody: unknown; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -82,37 +79,9 @@ describe("triggerAutofix", () => { }); }; - await triggerAutofix("test-org", "123456789", { - stoppingPoint: "root_cause", - }); + await triggerAutofix("test-org", "123456789"); - expect(capturedBody).toEqual({ stoppingPoint: "root_cause" }); - }); - - test("includes optional parameters when provided", async () => { - let capturedBody: unknown; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const req = new Request(input, init); - capturedBody = await req.json(); - - return new Response(JSON.stringify({ run_id: 12_345 }), { - status: 202, - headers: { "Content-Type": "application/json" }, - }); - }; - - await triggerAutofix("test-org", "123456789", { - stoppingPoint: "open_pr", - eventId: "event-abc", - instruction: "Focus on database issues", - }); - - expect(capturedBody).toEqual({ - stoppingPoint: "open_pr", - eventId: "event-abc", - instruction: "Focus on database issues", - }); + expect(capturedBody).toEqual({ step: "root_cause" }); }); test("throws ApiError on 402 response", async () => { @@ -216,7 +185,7 @@ describe("getAutofixState", () => { }); describe("updateAutofix", () => { - test("sends POST request to autofix update endpoint", async () => { + test("sends POST request to autofix endpoint", async () => { let capturedRequest: Request | undefined; let capturedBody: unknown; @@ -230,69 +199,15 @@ describe("updateAutofix", () => { }); }; - await updateAutofix("123456789", 12_345, { - type: "select_root_cause", - cause_id: 0, - stopping_point: "open_pr", - }); + await updateAutofix("test-org", "123456789", 12_345); expect(capturedRequest?.method).toBe("POST"); - expect(capturedRequest?.url).toContain("/issues/123456789/autofix/update/"); - expect(capturedBody).toEqual({ - run_id: 12_345, - payload: { - type: "select_root_cause", - cause_id: 0, - stopping_point: "open_pr", - }, - }); - }); - - test("sends select_solution payload", async () => { - let capturedBody: unknown; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - capturedBody = await new Request(input, init).json(); - - return new Response(JSON.stringify({}), { - status: 202, - headers: { "Content-Type": "application/json" }, - }); - }; - - await updateAutofix("123456789", 12_345, { - type: "select_solution", - }); - - expect(capturedBody).toEqual({ - run_id: 12_345, - payload: { - type: "select_solution", - }, - }); - }); - - test("sends create_pr payload", async () => { - let capturedBody: unknown; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - capturedBody = await new Request(input, init).json(); - - return new Response(JSON.stringify({}), { - status: 202, - headers: { "Content-Type": "application/json" }, - }); - }; - - await updateAutofix("123456789", 12_345, { - type: "create_pr", - }); - + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); expect(capturedBody).toEqual({ run_id: 12_345, - payload: { - type: "create_pr", - }, + step: "solution", }); }); }); @@ -381,154 +296,3 @@ describe("getIssueSummary", () => { await expect(getIssueSummary("test-org", "123456789")).rejects.toThrow(); }); }); - -describe("getAutofixExplorerState", () => { - test("sends GET request with mode=explorer parameter", async () => { - let capturedRequest: Request | undefined; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - capturedRequest = new Request(input, init); - - return new Response( - JSON.stringify({ - autofix: { - run_id: 12_345, - status: "COMPLETED", - blocks: [ - { - id: "block-1", - message: { role: "assistant", content: "Analysis complete" }, - timestamp: "2025-01-01T00:00:00Z", - artifacts: [ - { - key: "root_cause", - data: { - one_line_description: "Test root cause", - five_whys: ["Why 1", "Why 2"], - reproduction_steps: ["Step 1"], - }, - }, - ], - }, - ], - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - }; - - const result = await getAutofixExplorerState("test-org", "123456789"); - - expect(result?.run_id).toBe(12_345); - expect(result?.status).toBe("COMPLETED"); - expect(result?.blocks).toHaveLength(1); - expect(capturedRequest?.method).toBe("GET"); - expect(capturedRequest?.url).toContain( - "/organizations/test-org/issues/123456789/autofix/" - ); - expect(capturedRequest?.url).toContain("mode=explorer"); - }); - - test("returns null when autofix is null", async () => { - globalThis.fetch = async () => - new Response(JSON.stringify({ autofix: null }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - - const result = await getAutofixExplorerState("test-org", "123456789"); - expect(result).toBeNull(); - }); - - test("returns state with blocks and artifacts", async () => { - globalThis.fetch = async () => - new Response( - JSON.stringify({ - autofix: { - run_id: 12_345, - status: "COMPLETED", - blocks: [ - { - id: "block-1", - message: { role: "assistant", content: "Found root cause" }, - timestamp: "2025-01-01T00:00:00Z", - artifacts: [ - { - key: "root_cause", - data: { - one_line_description: "Memory leak in connection pool", - five_whys: [ - "Connections not released", - "Missing finally block", - ], - reproduction_steps: ["Open connection", "Don't close it"], - }, - }, - ], - }, - ], - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - - const result = await getAutofixExplorerState("test-org", "123456789"); - expect(result?.blocks?.[0]?.artifacts?.[0]?.key).toBe("root_cause"); - }); -}); - -describe("triggerAutofixAnalysis", () => { - test("sends POST request to autofix endpoint", async () => { - let capturedRequest: Request | undefined; - let capturedBody: unknown; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - capturedRequest = new Request(input, init); - capturedBody = await new Request(input, init).json(); - - return new Response(JSON.stringify({ run_id: 12_345 }), { - status: 202, - headers: { "Content-Type": "application/json" }, - }); - }; - - const result = await triggerAutofixAnalysis("test-org", "123456789"); - - expect(result.run_id).toBe(12_345); - expect(capturedRequest?.method).toBe("POST"); - expect(capturedRequest?.url).toContain( - "/organizations/test-org/issues/123456789/autofix/" - ); - expect(capturedBody).toEqual({}); - }); - - test("throws ApiError on 402 response (no budget)", async () => { - globalThis.fetch = async () => - new Response(JSON.stringify({ detail: "No budget for Seer Autofix" }), { - status: 402, - headers: { "Content-Type": "application/json" }, - }); - - await expect( - triggerAutofixAnalysis("test-org", "123456789") - ).rejects.toThrow(); - }); - - test("throws ApiError on 403 response (not enabled)", async () => { - globalThis.fetch = async () => - new Response(JSON.stringify({ detail: "AI Autofix is not enabled" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - - await expect( - triggerAutofixAnalysis("test-org", "123456789") - ).rejects.toThrow(); - }); -}); diff --git a/test/lib/formatters/autofix.test.ts b/test/lib/formatters/autofix.test.ts index e21eaa219..62d7e8742 100644 --- a/test/lib/formatters/autofix.test.ts +++ b/test/lib/formatters/autofix.test.ts @@ -12,19 +12,13 @@ import { formatProgressLine, formatPrResult, formatRootCause, - formatRootCauseAnalysisHeader, - formatRootCauseArtifact, formatRootCauseHeader, formatRootCauseList, getProgressMessage, getSpinnerFrame, truncateProgressMessage, } from "../../../src/lib/formatters/autofix.js"; -import type { - AutofixState, - RootCause, - RootCauseArtifact, -} from "../../../src/types/autofix.js"; +import type { AutofixState, RootCause } from "../../../src/types/autofix.js"; describe("getSpinnerFrame", () => { test("returns a spinner character", () => { @@ -297,131 +291,3 @@ describe("formatAutofixError", () => { expect(message).toBeTruthy(); }); }); - -describe("formatRootCauseAnalysisHeader", () => { - test("returns array with header and separator", () => { - const lines = formatRootCauseAnalysisHeader(); - expect(Array.isArray(lines)).toBe(true); - expect(lines.length).toBe(2); - expect(lines.join("\n")).toContain("Root Cause Analysis"); - }); -}); - -describe("formatRootCauseArtifact", () => { - test("formats complete root cause artifact", () => { - const artifact: RootCauseArtifact = { - key: "root_cause", - data: { - one_line_description: "Database connection pool exhausted", - five_whys: [ - "Connection pool ran out of connections", - "Connections were not being released", - "Missing finally block in database calls", - "Code review didn't catch the issue", - "No automated tests for connection cleanup", - ], - reproduction_steps: [ - "Start the application", - "Make 100 concurrent database requests", - "Observe connection timeout errors", - ], - }, - }; - - const lines = formatRootCauseArtifact(artifact); - const output = lines.join("\n"); - - // Check header - expect(output).toContain("Root Cause Analysis"); - - // Check summary - expect(output).toContain("Summary:"); - expect(output).toContain("Database connection pool exhausted"); - - // Check five whys - expect(output).toContain("Why This Happened:"); - expect(output).toContain("1. Connection pool ran out"); - expect(output).toContain("2. Connections were not being released"); - expect(output).toContain("5. No automated tests"); - - // Check reproduction steps - expect(output).toContain("Steps to Reproduce:"); - expect(output).toContain("1. Start the application"); - expect(output).toContain("3. Observe connection timeout"); - }); - - test("formats artifact with minimal data", () => { - const artifact: RootCauseArtifact = { - key: "root_cause", - data: { - one_line_description: "Simple error", - five_whys: [], - reproduction_steps: [], - }, - }; - - const lines = formatRootCauseArtifact(artifact); - const output = lines.join("\n"); - - expect(output).toContain("Root Cause Analysis"); - expect(output).toContain("Summary:"); - expect(output).toContain("Simple error"); - // Should not have numbered lists when arrays are empty - expect(output).not.toContain("1."); - }); - - test("formats artifact with only five_whys", () => { - const artifact: RootCauseArtifact = { - key: "root_cause", - data: { - one_line_description: "Error with whys only", - five_whys: ["First why", "Second why"], - reproduction_steps: [], - }, - }; - - const lines = formatRootCauseArtifact(artifact); - const output = lines.join("\n"); - - expect(output).toContain("Why This Happened:"); - expect(output).toContain("1. First why"); - expect(output).toContain("2. Second why"); - expect(output).not.toContain("Steps to Reproduce:"); - }); - - test("formats artifact with only reproduction_steps", () => { - const artifact: RootCauseArtifact = { - key: "root_cause", - data: { - one_line_description: "Error with steps only", - five_whys: [], - reproduction_steps: ["Step one", "Step two", "Step three"], - }, - }; - - const lines = formatRootCauseArtifact(artifact); - const output = lines.join("\n"); - - expect(output).not.toContain("Why This Happened:"); - expect(output).toContain("Steps to Reproduce:"); - expect(output).toContain("1. Step one"); - expect(output).toContain("3. Step three"); - }); - - test("includes reason field if present", () => { - const artifact: RootCauseArtifact = { - key: "root_cause", - data: { - one_line_description: "Test error", - five_whys: ["Why"], - reproduction_steps: ["Step"], - }, - reason: "Based on stack trace analysis", - }; - - const lines = formatRootCauseArtifact(artifact); - // The reason field is optional and may or may not be displayed - // Just verify the artifact formats without error - expect(lines.length).toBeGreaterThan(0); - }); -}); diff --git a/test/types/autofix.test.ts b/test/types/autofix.test.ts index 1fc4c835d..dc12b9d35 100644 --- a/test/types/autofix.test.ts +++ b/test/types/autofix.test.ts @@ -6,11 +6,9 @@ import { describe, expect, test } from "bun:test"; import { - type AutofixExplorerState, type AutofixState, type AutofixStep, extractPrUrl, - extractRootCauseArtifact, extractRootCauses, getLatestProgress, isTerminalStatus, @@ -305,136 +303,3 @@ describe("extractPrUrl", () => { expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/789"); }); }); - -describe("extractRootCauseArtifact", () => { - test("extracts root_cause artifact from blocks", () => { - const state: AutofixExplorerState = { - run_id: 123, - status: "COMPLETED", - blocks: [ - { - id: "block-1", - message: { role: "assistant", content: "Analyzing..." }, - timestamp: "2025-01-01T00:00:00Z", - }, - { - id: "block-2", - message: { role: "assistant", content: "Found root cause" }, - timestamp: "2025-01-01T00:01:00Z", - artifacts: [ - { - key: "root_cause", - data: { - one_line_description: "Database connection timeout", - five_whys: [ - "Connection pool exhausted", - "Too many concurrent requests", - "Missing connection limits", - ], - reproduction_steps: [ - "Start 100 concurrent requests", - "Wait for pool exhaustion", - "Observe timeout", - ], - }, - }, - ], - }, - ], - }; - - const artifact = extractRootCauseArtifact(state); - expect(artifact).not.toBeNull(); - expect(artifact?.key).toBe("root_cause"); - expect(artifact?.data.one_line_description).toBe( - "Database connection timeout" - ); - expect(artifact?.data.five_whys).toHaveLength(3); - expect(artifact?.data.reproduction_steps).toHaveLength(3); - }); - - test("returns null when no blocks", () => { - const state: AutofixExplorerState = { - run_id: 123, - status: "COMPLETED", - }; - - expect(extractRootCauseArtifact(state)).toBeNull(); - }); - - test("returns null when blocks have no artifacts", () => { - const state: AutofixExplorerState = { - run_id: 123, - status: "COMPLETED", - blocks: [ - { - id: "block-1", - message: { role: "assistant", content: "Processing" }, - timestamp: "2025-01-01T00:00:00Z", - }, - ], - }; - - expect(extractRootCauseArtifact(state)).toBeNull(); - }); - - test("returns null when no root_cause artifact exists", () => { - const state: AutofixExplorerState = { - run_id: 123, - status: "COMPLETED", - blocks: [ - { - id: "block-1", - message: { role: "assistant", content: "Found code" }, - timestamp: "2025-01-01T00:00:00Z", - artifacts: [ - { - key: "code_snippet", - data: { code: "const x = 1;" }, - }, - ], - }, - ], - }; - - expect(extractRootCauseArtifact(state)).toBeNull(); - }); - - test("finds root_cause in later blocks", () => { - const state: AutofixExplorerState = { - run_id: 123, - status: "COMPLETED", - blocks: [ - { - id: "block-1", - message: { role: "assistant", content: "Starting" }, - timestamp: "2025-01-01T00:00:00Z", - artifacts: [{ key: "other", data: {} }], - }, - { - id: "block-2", - message: { role: "assistant", content: "More analysis" }, - timestamp: "2025-01-01T00:01:00Z", - }, - { - id: "block-3", - message: { role: "assistant", content: "Found it" }, - timestamp: "2025-01-01T00:02:00Z", - artifacts: [ - { - key: "root_cause", - data: { - one_line_description: "Memory leak in loop", - five_whys: ["Objects not released"], - reproduction_steps: ["Run loop 1000 times"], - }, - }, - ], - }, - ], - }; - - const artifact = extractRootCauseArtifact(state); - expect(artifact?.data.one_line_description).toBe("Memory leak in loop"); - }); -}); From bd0bcc72d147c6988ab441d026f0a9c35b30da49 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 13:50:55 +0530 Subject: [PATCH 08/19] chore: removed unused functions --- src/commands/issue/utils.ts | 35 +----- src/lib/formatters/autofix.ts | 134 ---------------------- src/types/autofix.ts | 52 --------- src/types/index.ts | 2 - test/commands/issue/utils.test.ts | 82 -------------- test/lib/formatters/autofix.test.ts | 53 --------- test/types/autofix.test.ts | 167 ---------------------------- 7 files changed, 2 insertions(+), 523 deletions(-) diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index f55d94911..72d556c26 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -28,37 +28,6 @@ const ANIMATION_INTERVAL_MS = 80; /** Default timeout in milliseconds (10 minutes) */ const DEFAULT_TIMEOUT_MS = 600_000; -/** - * Resolve the numeric issue ID from either a numeric ID or short ID. - * Short IDs (e.g., MYPROJECT-ABC) require organization context. - * - * @param issueId - User-provided issue ID (numeric or short) - * @param org - Optional org slug for short ID resolution - * @param cwd - Current working directory for org resolution - * @param commandHint - Command example for error messages (e.g., "sentry issue explain ISSUE-123 --org ") - * @returns Numeric issue ID - * @throws {ContextError} When short ID provided without resolvable organization - */ -export async function resolveIssueId( - issueId: string, - org: string | undefined, - cwd: string, - commandHint: string -): Promise { - if (!isShortId(issueId)) { - return issueId; - } - - // Short ID requires organization context - const resolved = await resolveOrg({ org, cwd }); - if (!resolved) { - throw new ContextError("Organization", commandHint); - } - - const issue = await getIssueByShortId(resolved.org, issueId); - return issue.id; -} - type ResolvedIssue = { /** Resolved organization slug */ org: string; @@ -68,7 +37,7 @@ type ResolvedIssue = { /** * Resolve both organization slug and numeric issue ID. - * Required for endpoints that need both (like /summarize/). + * Required for autofix endpoints that need both org and issue ID. * * @param issueId - User-provided issue ID (numeric or short) * @param org - Optional org slug @@ -83,7 +52,7 @@ export async function resolveOrgAndIssueId( cwd: string, commandHint: string ): Promise { - // Always need org for endpoints like /summarize/ + // Always need org for endpoints like /autofix/ const resolved = await resolveOrg({ org, cwd }); if (!resolved) { throw new ContextError("Organization", commandHint); diff --git a/src/lib/formatters/autofix.ts b/src/lib/formatters/autofix.ts index f6b5012b9..222bffa05 100644 --- a/src/lib/formatters/autofix.ts +++ b/src/lib/formatters/autofix.ts @@ -7,7 +7,6 @@ import chalk from "chalk"; import type { AutofixState, - AutofixStep, RootCause, SolutionArtifact, } from "../../types/autofix.js"; @@ -97,24 +96,6 @@ export function getProgressMessage(state: AutofixState): string { } } -/** - * Get the current step title from autofix state. - */ -export function getCurrentStepTitle(state: AutofixState): string | undefined { - if (!state.steps) { - return; - } - - // Find the step that's currently processing - for (const step of state.steps) { - if (step.status === "PROCESSING" || step.status === "PENDING") { - return step.title; - } - } - - return; -} - // ───────────────────────────────────────────────────────────────────────────── // Root Cause Formatting // ───────────────────────────────────────────────────────────────────────────── @@ -218,121 +199,6 @@ export function formatRootCauseList( return lines; } -// ───────────────────────────────────────────────────────────────────────────── -// Fix / PR Result Formatting -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Format the PR creation result. - * - * @param prUrl - URL of the created PR - * @returns Array of formatted lines - */ -export function formatPrResult(prUrl: string): string[] { - return [ - "", - green("Pull Request Created"), - muted("═".repeat(21)), - "", - `URL: ${cyan(prUrl)}`, - ]; -} - -/** - * Format an error when no PR URL could be found. - */ -export function formatPrNotFound(): string[] { - return [ - "", - yellow("Fix process completed but no PR URL found."), - muted("Check the Sentry web UI for the autofix results."), - ]; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Status Formatting -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Format autofix status for display. - * - * @param status - Autofix status string - * @returns Colored status string - */ -export function formatAutofixStatus(status: string): string { - switch (status) { - case "COMPLETED": - return green("Completed"); - case "PROCESSING": - return cyan("Processing"); - case "ERROR": - return yellow("Error"); - case "CANCELLED": - return muted("Cancelled"); - case "WAITING_FOR_USER_RESPONSE": - return yellow("Waiting for input"); - default: - return status; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Step Formatting (for verbose/debug output) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Format an autofix step for display. - * - * @param step - The step to format - * @returns Array of formatted lines - */ -export function formatAutofixStep(step: AutofixStep): string[] { - const lines: string[] = []; - - let statusIcon: string; - if (step.status === "COMPLETED") { - statusIcon = green("✓"); - } else if (step.status === "PROCESSING") { - statusIcon = cyan("●"); - } else { - statusIcon = muted("○"); - } - - lines.push(`${statusIcon} ${step.title}`); - - // Show progress messages if any - if (step.progress && step.progress.length > 0) { - const lastProgress = step.progress.at(-1); - if (lastProgress) { - lines.push(` ${muted(lastProgress.message)}`); - } - } - - return lines; -} - -/** - * Format all steps summary. - * - * @param state - Autofix state - * @returns Array of formatted lines - */ -export function formatStepsSummary(state: AutofixState): string[] { - if (!state.steps || state.steps.length === 0) { - return []; - } - - const lines: string[] = []; - lines.push(""); - lines.push(muted("Steps:")); - - for (const step of state.steps) { - lines.push(...formatAutofixStep(step).map((line) => ` ${line}`)); - } - - return lines; -} - // ───────────────────────────────────────────────────────────────────────────── // Error Messages // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/types/autofix.ts b/src/types/autofix.ts index b085dbb77..ff0092e14 100644 --- a/src/types/autofix.ts +++ b/src/types/autofix.ts @@ -298,58 +298,6 @@ export function extractRootCauses(state: AutofixState): RootCause[] { return []; } -/** - * Get the latest progress message from autofix steps - */ -export function getLatestProgress(state: AutofixState): string | undefined { - if (!state.steps) { - return; - } - - // Find the step that's currently processing or most recently updated - for (let i = state.steps.length - 1; i >= 0; i--) { - const step = state.steps[i]; - if (step?.progress && step.progress.length > 0) { - const lastProgress = step.progress.at(-1); - return lastProgress?.message; - } - } - - return; -} - -/** - * Extract PR URL from completed autofix state - */ -export function extractPrUrl(state: AutofixState): string | undefined { - if (!state.steps) { - return; - } - - // Look for PR info in steps or coding_agents - for (const step of state.steps) { - if (step.key === "create_pr" || step.key === "changes") { - // PR URL might be in the step data - const stepData = step as unknown as Record; - if (typeof stepData.pr_url === "string") { - return stepData.pr_url; - } - } - } - - // Check coding_agents for PR info - if (state.coding_agents) { - for (const agent of Object.values(state.coding_agents)) { - const agentData = agent as Record; - if (typeof agentData.pr_url === "string") { - return agentData.pr_url; - } - } - } - - return; -} - /** Artifact structure used in blocks and steps */ type ArtifactEntry = { key: string; data: unknown; reason?: string }; diff --git a/src/types/index.ts b/src/types/index.ts index 1fbd0f684..2ce8f6868 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,10 +27,8 @@ export { AutofixStateSchema, AutofixStepSchema, AutofixTriggerResponseSchema, - extractPrUrl, extractRootCauses, extractSolution, - getLatestProgress, isTerminalStatus, RootCauseSchema, SolutionArtifactSchema, diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 275cffa76..058210bcc 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -9,7 +9,6 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { pollAutofixState, - resolveIssueId, resolveOrgAndIssueId, } from "../../../src/commands/issue/utils.js"; import { setAuthToken } from "../../../src/lib/config.js"; @@ -44,87 +43,6 @@ afterEach(() => { } }); -describe("resolveIssueId", () => { - test("returns numeric ID unchanged", async () => { - // No API call needed for numeric IDs - const result = await resolveIssueId( - "123456789", - undefined, - "/tmp", - "sentry issue explain 123456789 --org " - ); - - expect(result).toBe("123456789"); - }); - - test("returns numeric-looking ID unchanged", async () => { - const result = await resolveIssueId( - "9999999999", - undefined, - "/tmp", - "sentry issue explain 9999999999 --org " - ); - - expect(result).toBe("9999999999"); - }); - - test("resolves short ID when org is provided", async () => { - // Mock the API calls for short ID resolution - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - // Handle both string URLs and Request objects - const req = new Request(input, init); - const url = req.url; - - // Mock issue lookup by short ID (URL includes /api/0/) - if (url.includes("organizations/my-org/issues/PROJECT-ABC")) { - return new Response( - JSON.stringify({ - id: "987654321", - shortId: "PROJECT-ABC", - title: "Test Issue", - status: "unresolved", - platform: "javascript", - type: "error", - count: "10", - userCount: 5, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - } - - return new Response(JSON.stringify({ detail: "Not found" }), { - status: 404, - }); - }; - - const result = await resolveIssueId( - "PROJECT-ABC", - "my-org", - "/tmp", - "sentry issue explain PROJECT-ABC --org " - ); - - expect(result).toBe("987654321"); - }); - - test("throws ContextError when short ID provided without org", async () => { - // Clear any DSN/config that might provide org context - delete process.env.SENTRY_DSN; - - await expect( - resolveIssueId( - "PROJECT-ABC", - undefined, - "/nonexistent/path", - "sentry issue explain PROJECT-ABC --org " - ) - ).rejects.toThrow("Organization"); - }); -}); - describe("resolveOrgAndIssueId", () => { test("returns org and numeric issue ID when org is provided", async () => { const result = await resolveOrgAndIssueId( diff --git a/test/lib/formatters/autofix.test.ts b/test/lib/formatters/autofix.test.ts index 62d7e8742..f9b760dd3 100644 --- a/test/lib/formatters/autofix.test.ts +++ b/test/lib/formatters/autofix.test.ts @@ -7,12 +7,8 @@ import { describe, expect, test } from "bun:test"; import { formatAutofixError, - formatAutofixStatus, - formatPrNotFound, formatProgressLine, - formatPrResult, formatRootCause, - formatRootCauseHeader, formatRootCauseList, getProgressMessage, getSpinnerFrame, @@ -181,15 +177,6 @@ describe("formatRootCause", () => { }); }); -describe("formatRootCauseHeader", () => { - test("returns array of header lines", () => { - const lines = formatRootCauseHeader(); - expect(Array.isArray(lines)).toBe(true); - expect(lines.length).toBeGreaterThan(0); - expect(lines.join("\n")).toContain("Root Cause"); - }); -}); - describe("formatRootCauseList", () => { test("formats single cause with fix hint", () => { const causes: RootCause[] = [{ id: 0, description: "Single root cause" }]; @@ -220,46 +207,6 @@ describe("formatRootCauseList", () => { }); }); -describe("formatPrResult", () => { - test("formats PR URL display", () => { - const lines = formatPrResult("https://github.com/org/repo/pull/123"); - const output = lines.join("\n"); - expect(output).toContain("Pull Request"); - expect(output).toContain("https://github.com/org/repo/pull/123"); - }); -}); - -describe("formatPrNotFound", () => { - test("returns helpful message when no PR URL", () => { - const lines = formatPrNotFound(); - const output = lines.join("\n"); - expect(output).toContain("no PR URL"); - expect(output).toContain("Sentry web UI"); - }); -}); - -describe("formatAutofixStatus", () => { - test("formats COMPLETED status", () => { - const result = formatAutofixStatus("COMPLETED"); - expect(result.toLowerCase()).toContain("completed"); - }); - - test("formats PROCESSING status", () => { - const result = formatAutofixStatus("PROCESSING"); - expect(result.toLowerCase()).toContain("processing"); - }); - - test("formats ERROR status", () => { - const result = formatAutofixStatus("ERROR"); - expect(result.toLowerCase()).toContain("error"); - }); - - test("formats unknown status", () => { - const result = formatAutofixStatus("UNKNOWN_STATUS"); - expect(result).toBe("UNKNOWN_STATUS"); - }); -}); - describe("formatAutofixError", () => { test("formats 402 Payment Required", () => { const message = formatAutofixError(402); diff --git a/test/types/autofix.test.ts b/test/types/autofix.test.ts index dc12b9d35..2fd09a587 100644 --- a/test/types/autofix.test.ts +++ b/test/types/autofix.test.ts @@ -7,10 +7,7 @@ import { describe, expect, test } from "bun:test"; import { type AutofixState, - type AutofixStep, - extractPrUrl, extractRootCauses, - getLatestProgress, isTerminalStatus, TERMINAL_STATUSES, } from "../../src/types/autofix.js"; @@ -139,167 +136,3 @@ describe("extractRootCauses", () => { expect(extractRootCauses(state)).toEqual([]); }); }); - -describe("getLatestProgress", () => { - test("returns latest progress message from last step", () => { - const state: AutofixState = { - run_id: 123, - status: "PROCESSING", - steps: [ - { - id: "step-1", - key: "step_1", - status: "COMPLETED", - title: "Step 1", - progress: [ - { message: "First message", timestamp: "2025-01-01T00:00:00Z" }, - ], - }, - { - id: "step-2", - key: "step_2", - status: "PROCESSING", - title: "Step 2", - progress: [ - { message: "Second message", timestamp: "2025-01-01T00:01:00Z" }, - { message: "Latest message", timestamp: "2025-01-01T00:02:00Z" }, - ], - }, - ], - }; - - expect(getLatestProgress(state)).toBe("Latest message"); - }); - - test("returns message from earlier step if later steps have no progress", () => { - const state: AutofixState = { - run_id: 123, - status: "PROCESSING", - steps: [ - { - id: "step-1", - key: "step_1", - status: "COMPLETED", - title: "Step 1", - progress: [ - { message: "Has progress", timestamp: "2025-01-01T00:00:00Z" }, - ], - }, - { - id: "step-2", - key: "step_2", - status: "PROCESSING", - title: "Step 2", - progress: [], - }, - ], - }; - - expect(getLatestProgress(state)).toBe("Has progress"); - }); - - test("returns undefined when no steps", () => { - const state: AutofixState = { - run_id: 123, - status: "PROCESSING", - }; - - expect(getLatestProgress(state)).toBeUndefined(); - }); - - test("returns undefined when steps have no progress", () => { - const state: AutofixState = { - run_id: 123, - status: "PROCESSING", - steps: [ - { - id: "step-1", - key: "step_1", - status: "PROCESSING", - title: "Step 1", - }, - ], - }; - - expect(getLatestProgress(state)).toBeUndefined(); - }); -}); - -describe("extractPrUrl", () => { - test("returns undefined when no steps", () => { - const state: AutofixState = { - run_id: 123, - status: "COMPLETED", - }; - - expect(extractPrUrl(state)).toBeUndefined(); - }); - - test("returns undefined when steps have no PR info", () => { - const state: AutofixState = { - run_id: 123, - status: "COMPLETED", - steps: [ - { - id: "step-1", - key: "root_cause_analysis", - status: "COMPLETED", - title: "Analysis", - }, - ], - }; - - expect(extractPrUrl(state)).toBeUndefined(); - }); - - test("extracts PR URL from create_pr step", () => { - const state: AutofixState = { - run_id: 123, - status: "COMPLETED", - steps: [ - { - id: "step-1", - key: "create_pr", - status: "COMPLETED", - title: "Create PR", - pr_url: "https://github.com/org/repo/pull/123", - } as AutofixStep & { pr_url: string }, - ], - }; - - expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/123"); - }); - - test("extracts PR URL from changes step", () => { - const state: AutofixState = { - run_id: 123, - status: "COMPLETED", - steps: [ - { - id: "step-1", - key: "changes", - status: "COMPLETED", - title: "Changes", - pr_url: "https://github.com/org/repo/pull/456", - } as AutofixStep & { pr_url: string }, - ], - }; - - expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/456"); - }); - - test("extracts PR URL from coding_agents", () => { - const state: AutofixState = { - run_id: 123, - status: "COMPLETED", - steps: [], - coding_agents: { - agent_1: { - pr_url: "https://github.com/org/repo/pull/789", - }, - }, - }; - - expect(extractPrUrl(state)).toBe("https://github.com/org/repo/pull/789"); - }); -}); From c9663df4fe48bdd1b4e34c79b818b3d1a4fbe105 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 16:10:57 +0530 Subject: [PATCH 09/19] chore: minor changes --- src/commands/issue/explain.ts | 7 +- src/commands/issue/fix.ts | 9 ++- src/lib/api-client.ts | 36 ++------- test/lib/api-client.autofix.test.ts | 112 ++++------------------------ 4 files changed, 31 insertions(+), 133 deletions(-) diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index b15d70375..4076bac48 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -6,7 +6,10 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getAutofixState, triggerAutofix } from "../../lib/api-client.js"; +import { + getAutofixState, + triggerRootCauseAnalysis, +} from "../../lib/api-client.js"; import { ApiError } from "../../lib/errors.js"; import { formatAutofixError, @@ -91,7 +94,7 @@ export const explainCommand = buildCommand({ if (!flags.json) { stderr.write("Starting root cause analysis...\n"); } - await triggerAutofix(org, numericId); + await triggerRootCauseAnalysis(org, numericId); } // 3. Poll until complete (if not already completed) diff --git a/src/commands/issue/fix.ts b/src/commands/issue/fix.ts index 8d4974b1b..0a01ef45d 100644 --- a/src/commands/issue/fix.ts +++ b/src/commands/issue/fix.ts @@ -7,7 +7,10 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getAutofixState, updateAutofix } from "../../lib/api-client.js"; +import { + getAutofixState, + triggerSolutionPlanning, +} from "../../lib/api-client.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; import { formatAutofixError, @@ -198,8 +201,8 @@ export const fixCommand = buildCommand({ } } - // Update autofix to continue to PR creation - await updateAutofix(org, numericId, state.run_id); + // Trigger solution planning to continue to PR creation + await triggerSolutionPlanning(org, numericId, state.run_id); // Poll until PR is created const finalState = await pollAutofixState({ diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f19475899..e421cd40b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -14,8 +14,6 @@ import { AutofixTriggerResponseSchema, } from "../types/autofix.js"; import { - type IssueSummary, - IssueSummarySchema, type SentryEvent, SentryEventSchema, type SentryIssue, @@ -415,14 +413,14 @@ export function updateIssueStatus( // ───────────────────────────────────────────────────────────────────────────── /** - * Trigger an autofix run for an issue. + * Trigger root cause analysis for an issue using Seer AI. * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID * @returns The trigger response with run_id * @throws {ApiError} On API errors (402 = no budget, 403 = not enabled) */ -export function triggerAutofix( +export function triggerRootCauseAnalysis( orgSlug: string, issueId: string ): Promise { @@ -454,15 +452,15 @@ export async function getAutofixState( } /** - * Update an autofix run (e.g., select root cause, continue to PR). + * Trigger solution planning for an existing autofix run. + * Continues from root cause analysis to generate a solution. * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID * @param runId - The autofix run ID - * @param payload - The update payload (select_root_cause, select_solution, create_pr) * @returns The response from the API */ -export function updateAutofix( +export function triggerSolutionPlanning( orgSlug: string, issueId: string, runId: number @@ -475,27 +473,3 @@ export function updateAutofix( }, }); } - -// ───────────────────────────────────────────────────────────────────────────── -// Issue Summary API -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Get an AI-generated summary of an issue. - * - * @param orgSlug - The organization slug - * @param issueId - The numeric Sentry issue ID - * @returns The issue summary with headline, cause analysis, and scores - */ -export function getIssueSummary( - orgSlug: string, - issueId: string -): Promise { - return apiRequest( - `/organizations/${orgSlug}/issues/${issueId}/summarize/`, - { - method: "POST", - schema: IssueSummarySchema, - } - ); -} diff --git a/test/lib/api-client.autofix.test.ts b/test/lib/api-client.autofix.test.ts index 49374bb07..dda9b9141 100644 --- a/test/lib/api-client.autofix.test.ts +++ b/test/lib/api-client.autofix.test.ts @@ -1,7 +1,7 @@ /** - * Autofix and Summary API Client Tests + * Autofix API Client Tests * - * Tests for the autofix-related and summary API functions by mocking fetch. + * Tests for the autofix-related API functions by mocking fetch. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -9,9 +9,8 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { getAutofixState, - getIssueSummary, - triggerAutofix, - updateAutofix, + triggerRootCauseAnalysis, + triggerSolutionPlanning, } from "../../src/lib/api-client.js"; import { setAuthToken } from "../../src/lib/config.js"; @@ -45,7 +44,7 @@ afterEach(() => { } }); -describe("triggerAutofix", () => { +describe("triggerRootCauseAnalysis", () => { test("sends POST request to autofix endpoint", async () => { let capturedRequest: Request | undefined; @@ -58,7 +57,7 @@ describe("triggerAutofix", () => { }); }; - await triggerAutofix("test-org", "123456789"); + await triggerRootCauseAnalysis("test-org", "123456789"); expect(capturedRequest?.method).toBe("POST"); expect(capturedRequest?.url).toContain( @@ -79,7 +78,7 @@ describe("triggerAutofix", () => { }); }; - await triggerAutofix("test-org", "123456789"); + await triggerRootCauseAnalysis("test-org", "123456789"); expect(capturedBody).toEqual({ step: "root_cause" }); }); @@ -91,7 +90,9 @@ describe("triggerAutofix", () => { headers: { "Content-Type": "application/json" }, }); - await expect(triggerAutofix("test-org", "123456789")).rejects.toThrow(); + await expect( + triggerRootCauseAnalysis("test-org", "123456789") + ).rejects.toThrow(); }); test("throws ApiError on 403 response", async () => { @@ -101,7 +102,9 @@ describe("triggerAutofix", () => { headers: { "Content-Type": "application/json" }, }); - await expect(triggerAutofix("test-org", "123456789")).rejects.toThrow(); + await expect( + triggerRootCauseAnalysis("test-org", "123456789") + ).rejects.toThrow(); }); }); @@ -184,7 +187,7 @@ describe("getAutofixState", () => { }); }); -describe("updateAutofix", () => { +describe("triggerSolutionPlanning", () => { test("sends POST request to autofix endpoint", async () => { let capturedRequest: Request | undefined; let capturedBody: unknown; @@ -199,7 +202,7 @@ describe("updateAutofix", () => { }); }; - await updateAutofix("test-org", "123456789", 12_345); + await triggerSolutionPlanning("test-org", "123456789", 12_345); expect(capturedRequest?.method).toBe("POST"); expect(capturedRequest?.url).toContain( @@ -211,88 +214,3 @@ describe("updateAutofix", () => { }); }); }); - -describe("getIssueSummary", () => { - test("sends POST request to summarize endpoint", async () => { - let capturedRequest: Request | undefined; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - capturedRequest = new Request(input, init); - - return new Response( - JSON.stringify({ - groupId: "123456789", - headline: "Test Issue Summary", - whatsWrong: "Something went wrong", - trace: "Error in function X", - possibleCause: "Missing null check", - scores: { - possibleCauseConfidence: 0.85, - possibleCauseNovelty: 0.6, - isFixable: true, - fixabilityScore: 0.7, - fixabilityScoreVersion: "1.0", - }, - eventId: "abc123", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - }; - - const result = await getIssueSummary("test-org", "123456789"); - - expect(result.groupId).toBe("123456789"); - expect(result.headline).toBe("Test Issue Summary"); - expect(result.whatsWrong).toBe("Something went wrong"); - expect(result.possibleCause).toBe("Missing null check"); - expect(result.scores?.possibleCauseConfidence).toBe(0.85); - expect(capturedRequest?.method).toBe("POST"); - expect(capturedRequest?.url).toContain( - "/organizations/test-org/issues/123456789/summarize/" - ); - }); - - test("returns summary with minimal fields", async () => { - globalThis.fetch = async () => - new Response( - JSON.stringify({ - groupId: "123456789", - headline: "Simple Error", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - - const result = await getIssueSummary("test-org", "123456789"); - - expect(result.groupId).toBe("123456789"); - expect(result.headline).toBe("Simple Error"); - expect(result.whatsWrong).toBeUndefined(); - expect(result.scores).toBeUndefined(); - }); - - test("throws ApiError on 404 response", async () => { - globalThis.fetch = async () => - new Response(JSON.stringify({ detail: "Issue not found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); - - await expect(getIssueSummary("test-org", "123456789")).rejects.toThrow(); - }); - - test("throws ApiError on 403 response", async () => { - globalThis.fetch = async () => - new Response(JSON.stringify({ detail: "AI features not enabled" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - - await expect(getIssueSummary("test-org", "123456789")).rejects.toThrow(); - }); -}); From 5fca2dbc7c93dff4b25dced5b7d640fc5a5a2749 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 16:24:54 +0530 Subject: [PATCH 10/19] chore: minor changes --- src/lib/api-client.ts | 12 +++--------- src/types/autofix.ts | 12 ------------ src/types/index.ts | 30 ++++-------------------------- src/types/sentry.ts | 42 ------------------------------------------ 4 files changed, 7 insertions(+), 89 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e421cd40b..49ecfe2f1 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -7,12 +7,7 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; -import { - type AutofixResponse, - type AutofixState, - type AutofixTriggerResponse, - AutofixTriggerResponseSchema, -} from "../types/autofix.js"; +import type { AutofixResponse, AutofixState } from "../types/autofix.js"; import { type SentryEvent, SentryEventSchema, @@ -423,13 +418,12 @@ export function updateIssueStatus( export function triggerRootCauseAnalysis( orgSlug: string, issueId: string -): Promise { - return apiRequest( +): Promise<{ run_id: number }> { + return apiRequest<{ run_id: number }>( `/organizations/${orgSlug}/issues/${issueId}/autofix/`, { method: "POST", body: { step: "root_cause" }, - schema: AutofixTriggerResponseSchema, } ); } diff --git a/src/types/autofix.ts b/src/types/autofix.ts index ff0092e14..573a9e686 100644 --- a/src/types/autofix.ts +++ b/src/types/autofix.ts @@ -39,18 +39,6 @@ export const STOPPING_POINTS = [ export type StoppingPoint = (typeof STOPPING_POINTS)[number]; -// ───────────────────────────────────────────────────────────────────────────── -// Trigger Response -// ───────────────────────────────────────────────────────────────────────────── - -export const AutofixTriggerResponseSchema = z.object({ - run_id: z.number(), -}); - -export type AutofixTriggerResponse = z.infer< - typeof AutofixTriggerResponseSchema ->; - // ───────────────────────────────────────────────────────────────────────────── // Progress Message // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/types/index.ts b/src/types/index.ts index 2ce8f6868..5dc18b606 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,68 +7,46 @@ // DSN types export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; -// Autofix types export type { AutofixResponse, AutofixState, - AutofixStatus, - AutofixStep, - AutofixTriggerResponse, - AutofixUpdatePayload, RootCause, SolutionArtifact, - SolutionData, - SolutionStep, - StoppingPoint, } from "./autofix.js"; +// Autofix types export { - AUTOFIX_STATUSES, - AutofixResponseSchema, - AutofixStateSchema, - AutofixStepSchema, - AutofixTriggerResponseSchema, extractRootCauses, extractSolution, isTerminalStatus, - RootCauseSchema, SolutionArtifactSchema, - SolutionDataSchema, - SolutionStepSchema, - STOPPING_POINTS, TERMINAL_STATUSES, } from "./autofix.js"; -// Configuration types export type { CachedProject, SentryConfig } from "./config.js"; +// Configuration types export { SentryConfigSchema } from "./config.js"; - -// OAuth types and schemas export type { DeviceCodeResponse, TokenErrorResponse, TokenResponse, } from "./oauth.js"; +// OAuth types and schemas export { DeviceCodeResponseSchema, TokenErrorResponseSchema, TokenResponseSchema, } from "./oauth.js"; - -// Sentry API types and schemas export type { IssueLevel, IssueStatus, - IssueSummary, - IssueSummaryScores, SentryEvent, SentryIssue, SentryOrganization, SentryProject, } from "./sentry.js"; +// Sentry API types and schemas export { ISSUE_LEVELS, ISSUE_STATUSES, - IssueSummarySchema, - IssueSummaryScoresSchema, SentryEventSchema, SentryIssueSchema, SentryOrganizationSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 092e79cfd..8b0b0ff94 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -224,45 +224,3 @@ export const SentryEventSchema = z .passthrough(); export type SentryEvent = z.infer; - -// ───────────────────────────────────────────────────────────────────────────── -// Issue Summary (AI-generated analysis) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Scores from the AI analysis indicating confidence and fixability. - */ -export const IssueSummaryScoresSchema = z - .object({ - possibleCauseConfidence: z.number().nullable(), - possibleCauseNovelty: z.number().nullable(), - isFixable: z.boolean().nullable(), - fixabilityScore: z.number().nullable(), - fixabilityScoreVersion: z.string().nullable(), - }) - .passthrough(); - -/** - * AI-generated summary of an issue from the /summarize/ endpoint. - */ -export const IssueSummarySchema = z - .object({ - /** Issue group ID */ - groupId: z.string(), - /** One-line summary of the issue */ - headline: z.string(), - /** Description of what's wrong */ - whatsWrong: z.string().optional(), - /** Trace analysis */ - trace: z.string().optional(), - /** Possible cause of the issue */ - possibleCause: z.string().optional(), - /** Confidence and fixability scores */ - scores: IssueSummaryScoresSchema.optional(), - /** Event ID that was analyzed */ - eventId: z.string().optional(), - }) - .passthrough(); - -export type IssueSummary = z.infer; -export type IssueSummaryScores = z.infer; From 6a4a6dc00f7fce574dd7346dc6f670d4237b82db Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 16:28:43 +0530 Subject: [PATCH 11/19] fix: removed the summary formatters --- src/lib/formatters/index.ts | 1 - src/lib/formatters/summary.ts | 67 ----------------------------------- 2 files changed, 68 deletions(-) delete mode 100644 src/lib/formatters/summary.ts diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index c62de98de..b07533b1c 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -10,4 +10,3 @@ export * from "./colors.js"; export * from "./human.js"; export * from "./json.js"; export * from "./output.js"; -export * from "./summary.js"; diff --git a/src/lib/formatters/summary.ts b/src/lib/formatters/summary.ts deleted file mode 100644 index 163adb1e0..000000000 --- a/src/lib/formatters/summary.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Issue Summary Output Formatters - * - * Formatting utilities for AI-generated issue summaries. - */ - -import chalk from "chalk"; -import type { IssueSummary } from "../../types/index.js"; -import { cyan, green, muted, yellow } from "./colors.js"; - -const bold = (text: string): string => chalk.bold(text); - -/** - * Format an issue summary for human-readable output. - * - * @param summary - The issue summary from the API - * @returns Array of formatted lines - */ -export function formatIssueSummary(summary: IssueSummary): string[] { - const lines: string[] = []; - - // Headline - lines.push(""); - lines.push(bold(summary.headline)); - lines.push(""); - - // What's Wrong - if (summary.whatsWrong) { - lines.push(yellow("What's Wrong:")); - lines.push(` ${summary.whatsWrong}`); - lines.push(""); - } - - // Trace - if (summary.trace) { - lines.push(cyan("Trace:")); - lines.push(` ${summary.trace}`); - lines.push(""); - } - - // Possible Cause - if (summary.possibleCause) { - lines.push(green("Possible Cause:")); - lines.push(` ${summary.possibleCause}`); - lines.push(""); - } - - // Confidence score - if (summary.scores?.possibleCauseConfidence !== null) { - const confidence = summary.scores?.possibleCauseConfidence; - if (confidence !== undefined) { - const percent = Math.round(confidence * 100); - lines.push(muted(`Confidence: ${percent}%`)); - } - } - - return lines; -} - -/** - * Format an issue summary header for display. - * - * @returns Array of header lines - */ -export function formatSummaryHeader(): string[] { - return [bold("Issue Summary"), "═".repeat(60)]; -} From 5b6b06494ea0572332a76ed85f3e36e42a550f6e Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 16:31:18 +0530 Subject: [PATCH 12/19] chore: removed the test file --- test/lib/formatters/summary.test.ts | 124 ---------------------------- 1 file changed, 124 deletions(-) delete mode 100644 test/lib/formatters/summary.test.ts diff --git a/test/lib/formatters/summary.test.ts b/test/lib/formatters/summary.test.ts deleted file mode 100644 index 3bb574927..000000000 --- a/test/lib/formatters/summary.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Issue Summary Formatter Tests - * - * Tests for formatting functions in src/lib/formatters/summary.ts - */ - -import { describe, expect, test } from "bun:test"; -import { - formatIssueSummary, - formatSummaryHeader, -} from "../../../src/lib/formatters/summary.js"; -import type { IssueSummary } from "../../../src/types/index.js"; - -describe("formatIssueSummary", () => { - test("formats summary with all fields", () => { - const summary: IssueSummary = { - groupId: "123456789", - headline: "Database Connection Timeout", - whatsWrong: "Connection pool exhausted due to missing cleanup", - trace: "Timeout occurred in db.query() after 30s", - possibleCause: "Connection leak in transaction handling", - scores: { - possibleCauseConfidence: 0.85, - possibleCauseNovelty: 0.6, - isFixable: true, - fixabilityScore: 0.7, - fixabilityScoreVersion: "1.0", - }, - eventId: "abc123", - }; - - const lines = formatIssueSummary(summary); - const output = lines.join("\n"); - - expect(output).toContain("Database Connection Timeout"); - expect(output).toContain("What's Wrong:"); - expect(output).toContain("Connection pool exhausted"); - expect(output).toContain("Trace:"); - expect(output).toContain("Timeout occurred"); - expect(output).toContain("Possible Cause:"); - expect(output).toContain("Connection leak"); - expect(output).toContain("Confidence: 85%"); - }); - - test("formats summary with only headline", () => { - const summary: IssueSummary = { - groupId: "123456789", - headline: "Simple Error", - }; - - const lines = formatIssueSummary(summary); - const output = lines.join("\n"); - - expect(output).toContain("Simple Error"); - expect(output).not.toContain("What's Wrong:"); - expect(output).not.toContain("Trace:"); - expect(output).not.toContain("Possible Cause:"); - }); - - test("formats summary without scores", () => { - const summary: IssueSummary = { - groupId: "123456789", - headline: "Test Error", - whatsWrong: "Something went wrong", - }; - - const lines = formatIssueSummary(summary); - const output = lines.join("\n"); - - expect(output).toContain("Test Error"); - expect(output).toContain("Something went wrong"); - expect(output).not.toContain("Confidence:"); - }); - - test("formats summary with null confidence score", () => { - const summary: IssueSummary = { - groupId: "123456789", - headline: "Test Error", - scores: { - possibleCauseConfidence: null, - possibleCauseNovelty: null, - isFixable: null, - fixabilityScore: null, - fixabilityScoreVersion: null, - }, - }; - - const lines = formatIssueSummary(summary); - const output = lines.join("\n"); - - expect(output).toContain("Test Error"); - // Should not show confidence when it's null - expect(output).not.toContain("Confidence:"); - }); - - test("rounds confidence percentage", () => { - const summary: IssueSummary = { - groupId: "123456789", - headline: "Test Error", - scores: { - possibleCauseConfidence: 0.567, - possibleCauseNovelty: null, - isFixable: null, - fixabilityScore: null, - fixabilityScoreVersion: null, - }, - }; - - const lines = formatIssueSummary(summary); - const output = lines.join("\n"); - - expect(output).toContain("Confidence: 57%"); - }); -}); - -describe("formatSummaryHeader", () => { - test("returns header lines", () => { - const lines = formatSummaryHeader(); - - expect(Array.isArray(lines)).toBe(true); - expect(lines.length).toBeGreaterThan(0); - expect(lines.join("\n")).toContain("Issue Summary"); - }); -}); From 22a22801cf76ad48c7dfff3888b934d19aa11728 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 17:32:45 +0530 Subject: [PATCH 13/19] fix: renamed the command to "plan" --- src/commands/issue/index.ts | 6 ++--- src/commands/issue/{fix.ts => plan.ts} | 36 +++++++++++++------------- src/commands/issue/utils.ts | 2 +- src/lib/formatters/autofix.ts | 2 +- test/lib/formatters/autofix.test.ts | 8 +++--- 5 files changed, 27 insertions(+), 27 deletions(-) rename src/commands/issue/{fix.ts => plan.ts} (86%) diff --git a/src/commands/issue/index.ts b/src/commands/issue/index.ts index ac1e58c35..2f05338fd 100644 --- a/src/commands/issue/index.ts +++ b/src/commands/issue/index.ts @@ -1,15 +1,15 @@ import { buildRouteMap } from "@stricli/core"; import { explainCommand } from "./explain.js"; -import { fixCommand } from "./fix.js"; import { getCommand } from "./get.js"; import { listCommand } from "./list.js"; +import { planCommand } from "./plan.js"; export const issueRoute = buildRouteMap({ routes: { list: listCommand, get: getCommand, explain: explainCommand, - fix: fixCommand, + plan: planCommand, }, docs: { brief: "Manage Sentry issues", @@ -19,7 +19,7 @@ export const issueRoute = buildRouteMap({ " list List issues in a project\n" + " get Get details of a specific issue\n" + " explain Analyze an issue using Seer AI\n" + - " fix Create a PR with a fix using Seer AI", + " plan Create a PR with a plan using Seer AI", hideRoute: {}, }, }); diff --git a/src/commands/issue/fix.ts b/src/commands/issue/plan.ts similarity index 86% rename from src/commands/issue/fix.ts rename to src/commands/issue/plan.ts index 0a01ef45d..cbe07f023 100644 --- a/src/commands/issue/fix.ts +++ b/src/commands/issue/plan.ts @@ -1,7 +1,7 @@ /** - * sentry issue fix + * sentry issue plan * - * Create a pull request with a fix for a Sentry issue using Seer AI. + * Create a pull request with a plan for a Sentry issue using Seer AI. * Requires that 'sentry issue explain' has been run first. */ @@ -26,7 +26,7 @@ import { } from "../../types/autofix.js"; import { pollAutofixState, resolveOrgAndIssueId } from "./utils.js"; -type FixFlags = { +type PlanFlags = { readonly org?: string; readonly cause?: number; readonly json: boolean; @@ -64,14 +64,14 @@ function validateAutofixState( ); } throw new ValidationError( - `Cannot create fix: autofix is in '${state.status}' state.` + `Cannot create plan: autofix is in '${state.status}' state.` ); } const causes = extractRootCauses(state); if (causes.length === 0) { throw new ValidationError( - "No root causes identified. Cannot create a fix without a root cause." + "No root causes identified. Cannot create a plan without a root cause." ); } @@ -104,7 +104,7 @@ function validateCauseSelection( } } lines.push(""); - lines.push(`Example: sentry issue fix ${issueId} --cause 0`); + lines.push(`Example: sentry issue plan ${issueId} --cause 0`); throw new ValidationError(lines.join("\n")); } @@ -120,22 +120,22 @@ function validateCauseSelection( return causeId; } -export const fixCommand = buildCommand({ +export const planCommand = buildCommand({ docs: { - brief: "Create a PR with a fix using Seer AI", + brief: "Create a PR with a plan using Seer AI", fullDescription: - "Create a pull request with a fix for a Sentry issue using Seer AI.\n\n" + + "Create a pull request with a plan for a Sentry issue using Seer AI.\n\n" + "This command requires that 'sentry issue explain' has been run first " + "to identify the root cause. It will then generate code changes and " + - "create a pull request with the fix.\n\n" + + "create a pull request with the plan.\n\n" + "If multiple root causes were identified, use --cause to specify which one.\n\n" + "Prerequisites:\n" + " - GitHub integration configured for your organization\n" + " - Code mappings set up for your project\n" + " - Repository write access for the integration\n\n" + "Examples:\n" + - " sentry issue fix 123456789 --cause 0\n" + - " sentry issue fix MYPROJECT-ABC --org my-org --cause 1", + " sentry issue plan 123456789 --cause 0\n" + + " sentry issue plan MYPROJECT-ABC --org my-org --cause 1", }, parameters: { positional: { @@ -158,7 +158,7 @@ export const fixCommand = buildCommand({ cause: { kind: "parsed", parse: numberParser, - brief: "Root cause ID to fix (required if multiple causes exist)", + brief: "Root cause ID to plan (required if multiple causes exist)", optional: true, }, json: { @@ -170,7 +170,7 @@ export const fixCommand = buildCommand({ }, async func( this: SentryContext, - flags: FixFlags, + flags: PlanFlags, issueId: string ): Promise { const { stdout, stderr, cwd } = this; @@ -181,7 +181,7 @@ export const fixCommand = buildCommand({ issueId, flags.org, cwd, - `sentry issue fix ${issueId} --org ` + `sentry issue plan ${issueId} --org ` ); // Get current autofix state @@ -195,7 +195,7 @@ export const fixCommand = buildCommand({ const selectedCause = causes[causeId]; if (!flags.json) { - stderr.write(`Creating fix for cause #${causeId}...\n`); + stderr.write(`Creating plan for cause #${causeId}...\n`); if (selectedCause) { stderr.write(`${muted(`"${selectedCause.description}"`)}\n\n`); } @@ -217,12 +217,12 @@ export const fixCommand = buildCommand({ // Handle errors if (finalState.status === "ERROR") { throw new Error( - "Fix creation failed. Check the Sentry web UI for details." + "Plan creation failed. Check the Sentry web UI for details." ); } if (finalState.status === "CANCELLED") { - throw new Error("Fix creation was cancelled."); + throw new Error("Plan creation was cancelled."); } // Extract solution artifact diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 72d556c26..a8f5c021b 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -1,7 +1,7 @@ /** * Shared utilities for issue commands * - * Common functionality used by explain, fix, and other issue commands. + * Common functionality used by explain, plan, and other issue commands. */ import { diff --git a/src/lib/formatters/autofix.ts b/src/lib/formatters/autofix.ts index 222bffa05..a561ae8e0 100644 --- a/src/lib/formatters/autofix.ts +++ b/src/lib/formatters/autofix.ts @@ -194,7 +194,7 @@ export function formatRootCauseList( // Add hint for next steps lines.push(""); - lines.push(muted(`To create a fix, run: sentry issue fix ${issueId}`)); + lines.push(muted(`To create a plan, run: sentry issue plan ${issueId}`)); return lines; } diff --git a/test/lib/formatters/autofix.test.ts b/test/lib/formatters/autofix.test.ts index f9b760dd3..4cf2431d2 100644 --- a/test/lib/formatters/autofix.test.ts +++ b/test/lib/formatters/autofix.test.ts @@ -178,16 +178,16 @@ describe("formatRootCause", () => { }); describe("formatRootCauseList", () => { - test("formats single cause with fix hint", () => { + test("formats single cause with plan hint", () => { const causes: RootCause[] = [{ id: 0, description: "Single root cause" }]; const lines = formatRootCauseList(causes, "ISSUE-123"); const output = lines.join("\n"); expect(output).toContain("Single root cause"); - expect(output).toContain("sentry issue fix ISSUE-123"); + expect(output).toContain("sentry issue plan ISSUE-123"); }); - test("formats multiple causes with fix hint", () => { + test("formats multiple causes with plan hint", () => { const causes: RootCause[] = [ { id: 0, description: "First cause" }, { id: 1, description: "Second cause" }, @@ -197,7 +197,7 @@ describe("formatRootCauseList", () => { const output = lines.join("\n"); expect(output).toContain("First cause"); expect(output).toContain("Second cause"); - expect(output).toContain("sentry issue fix ISSUE-456"); + expect(output).toContain("sentry issue plan ISSUE-456"); }); test("handles empty causes array", () => { From 88524d5c8a4bc09b7ce05964156398c7bb062d36 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 17:34:33 +0530 Subject: [PATCH 14/19] chore: minor change --- src/lib/api-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 49ecfe2f1..4c77e618e 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -431,6 +431,7 @@ export function triggerRootCauseAnalysis( /** * Get the current autofix state for an issue. * + * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID * @returns The autofix state, or null if no autofix has been run */ From cc2bf3b35c3acb7e24a979915361cb83354111af Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:46:09 +0530 Subject: [PATCH 15/19] Update src/commands/issue/utils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/issue/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index a8f5c021b..a5bdb15f4 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -76,7 +76,7 @@ type PollAutofixOptions = { stderr: Writer; /** Whether to suppress progress output (JSON mode) */ json: boolean; - /** Polling interval in milliseconds (default: 3000) */ + /** Polling interval in milliseconds (default: 1000) */ pollIntervalMs?: number; /** Maximum time to wait in milliseconds (default: 600000 = 10 minutes) */ timeoutMs?: number; From fc73fd8cc2b453a4f3d152b728d309d1f7453736 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 23 Jan 2026 17:52:50 +0530 Subject: [PATCH 16/19] fix: updated the docs for plan command --- src/commands/issue/index.ts | 2 +- src/commands/issue/plan.ts | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/commands/issue/index.ts b/src/commands/issue/index.ts index 2f05338fd..8b0cab40f 100644 --- a/src/commands/issue/index.ts +++ b/src/commands/issue/index.ts @@ -19,7 +19,7 @@ export const issueRoute = buildRouteMap({ " list List issues in a project\n" + " get Get details of a specific issue\n" + " explain Analyze an issue using Seer AI\n" + - " plan Create a PR with a plan using Seer AI", + " plan Generate a solution plan using Seer AI", hideRoute: {}, }, }); diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index cbe07f023..e5960c925 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -1,7 +1,7 @@ /** * sentry issue plan * - * Create a pull request with a plan for a Sentry issue using Seer AI. + * Generate a solution plan for a Sentry issue using Seer AI. * Requires that 'sentry issue explain' has been run first. */ @@ -122,17 +122,16 @@ function validateCauseSelection( export const planCommand = buildCommand({ docs: { - brief: "Create a PR with a plan using Seer AI", + brief: "Generate a solution plan using Seer AI", fullDescription: - "Create a pull request with a plan for a Sentry issue using Seer AI.\n\n" + + "Generate a solution plan for a Sentry issue using Seer AI.\n\n" + "This command requires that 'sentry issue explain' has been run first " + - "to identify the root cause. It will then generate code changes and " + - "create a pull request with the plan.\n\n" + + "to identify the root cause. It will then generate a solution plan with " + + "specific implementation steps to fix the issue.\n\n" + "If multiple root causes were identified, use --cause to specify which one.\n\n" + "Prerequisites:\n" + " - GitHub integration configured for your organization\n" + - " - Code mappings set up for your project\n" + - " - Repository write access for the integration\n\n" + + " - Code mappings set up for your project\n\n" + "Examples:\n" + " sentry issue plan 123456789 --cause 0\n" + " sentry issue plan MYPROJECT-ABC --org my-org --cause 1", @@ -201,7 +200,7 @@ export const planCommand = buildCommand({ } } - // Trigger solution planning to continue to PR creation + // Trigger solution planning to generate implementation steps await triggerSolutionPlanning(org, numericId, state.run_id); // Poll until PR is created From 61e4e140624614e44bcdc8da12926cf018bbc8fc Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Sat, 24 Jan 2026 00:14:44 +0530 Subject: [PATCH 17/19] fix: corrected the import --- src/commands/issue/utils.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index a5bdb15f4..9b523dfb9 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -4,17 +4,14 @@ * Common functionality used by explain, plan, and other issue commands. */ -import { - getAutofixState, - getIssueByShortId, - isShortId, -} from "../../lib/api-client.js"; +import { getAutofixState, getIssueByShortId } from "../../lib/api-client.js"; import { ContextError } from "../../lib/errors.js"; import { formatProgressLine, getProgressMessage, truncateProgressMessage, } from "../../lib/formatters/autofix.js"; +import { isShortId } from "../../lib/issue-id.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { type AutofixState, isTerminalStatus } from "../../types/autofix.js"; import type { Writer } from "../../types/index.js"; From 585271fabf04e0bc791b5d7262329ac2e58063db Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 26 Jan 2026 19:34:14 +0530 Subject: [PATCH 18/19] fix: improvements --- src/commands/issue/explain.ts | 29 +++-- src/commands/issue/plan.ts | 18 ++- src/commands/issue/utils.ts | 81 ++++-------- src/lib/api-client.ts | 2 +- src/lib/formatters/index.ts | 2 +- src/lib/formatters/{autofix.ts => seer.ts} | 9 +- src/lib/polling.ts | 123 ++++++++++++++++++ src/types/index.ts | 29 ++--- src/types/{autofix.ts => seer.ts} | 14 +- ...utofix.test.ts => api-client.seer.test.ts} | 6 +- .../{autofix.test.ts => seer.test.ts} | 8 +- test/types/{autofix.test.ts => seer.test.ts} | 6 +- 12 files changed, 222 insertions(+), 105 deletions(-) rename src/lib/formatters/{autofix.ts => seer.ts} (97%) create mode 100644 src/lib/polling.ts rename src/types/{autofix.ts => seer.ts} (97%) rename test/lib/{api-client.autofix.test.ts => api-client.seer.test.ts} (97%) rename test/lib/formatters/{autofix.test.ts => seer.test.ts} (97%) rename test/types/{autofix.test.ts => seer.test.ts} (96%) diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 4076bac48..6b395e7a8 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -11,17 +11,18 @@ import { triggerRootCauseAnalysis, } from "../../lib/api-client.js"; import { ApiError } from "../../lib/errors.js"; +import { writeJson } from "../../lib/formatters/index.js"; import { formatAutofixError, formatRootCauseList, -} from "../../lib/formatters/autofix.js"; -import { writeJson } from "../../lib/formatters/index.js"; -import { extractRootCauses } from "../../types/autofix.js"; +} from "../../lib/formatters/seer.js"; +import { extractRootCauses } from "../../types/seer.js"; import { pollAutofixState, resolveOrgAndIssueId } from "./utils.js"; type ExplainFlags = { readonly org?: string; readonly json: boolean; + readonly force: boolean; }; export const explainCommand = buildCommand({ @@ -33,11 +34,13 @@ export const explainCommand = buildCommand({ " - Identified root causes\n" + " - Reproduction steps\n" + " - Relevant code locations\n\n" + - "The analysis may take a few minutes for new issues.\n\n" + + "The analysis may take a few minutes for new issues.\n" + + "Use --force to trigger a fresh analysis even if one already exists.\n\n" + "Examples:\n" + " sentry issue explain 123456789\n" + " sentry issue explain MYPROJECT-ABC --org my-org\n" + - " sentry issue explain 123456789 --json", + " sentry issue explain 123456789 --json\n" + + " sentry issue explain 123456789 --force", }, parameters: { positional: { @@ -62,6 +65,11 @@ export const explainCommand = buildCommand({ brief: "Output as JSON", default: false, }, + force: { + kind: "boolean", + brief: "Force new analysis even if one exists", + default: false, + }, }, }, async func( @@ -80,8 +88,8 @@ export const explainCommand = buildCommand({ `sentry issue explain ${issueId} --org ` ); - // 1. Check for existing analysis - let state = await getAutofixState(org, numericId); + // 1. Check for existing analysis (skip if --force) + let state = flags.force ? null : await getAutofixState(org, numericId); // Handle error status, we are gonna retry the analysis if (state?.status === "ERROR") { @@ -89,10 +97,13 @@ export const explainCommand = buildCommand({ state = null; } - // 2. Trigger new analysis if none exists + // 2. Trigger new analysis if none exists or forced if (!state) { if (!flags.json) { - stderr.write("Starting root cause analysis...\n"); + const message = flags.force + ? "Forcing fresh root cause analysis...\n" + : "Starting root cause analysis...\n"; + stderr.write(message); } await triggerRootCauseAnalysis(org, numericId); } diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index e5960c925..2dde1e18f 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -12,18 +12,18 @@ import { triggerSolutionPlanning, } from "../../lib/api-client.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; +import { muted } from "../../lib/formatters/colors.js"; +import { writeJson } from "../../lib/formatters/index.js"; import { formatAutofixError, formatSolution, -} from "../../lib/formatters/autofix.js"; -import { muted } from "../../lib/formatters/colors.js"; -import { writeJson } from "../../lib/formatters/index.js"; +} from "../../lib/formatters/seer.js"; import { type AutofixState, extractRootCauses, extractSolution, type RootCause, -} from "../../types/autofix.js"; +} from "../../types/seer.js"; import { pollAutofixState, resolveOrgAndIssueId } from "./utils.js"; type PlanFlags = { @@ -79,7 +79,13 @@ function validateAutofixState( } /** - * Validate the cause selection. + * Validate and resolve the cause selection for solution planning. + * + * @param causes - Array of available root causes + * @param selectedCause - User-specified cause index, or undefined for auto-select + * @param issueId - Issue ID for error message hints + * @returns Validated cause index (0-based) + * @throws {ValidationError} If multiple causes exist without selection, or if selection is out of range */ function validateCauseSelection( causes: RootCause[], @@ -210,7 +216,7 @@ export const planCommand = buildCommand({ stderr, json: flags.json, timeoutMessage: - "PR creation timed out after 10 minutes. Check the issue in Sentry web UI.", + "Plan creation timed out after 3 minutes. Try again or check the issue in Sentry web UI.", }); // Handle errors diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 9b523dfb9..80ff5bc6e 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -6,24 +6,15 @@ import { getAutofixState, getIssueByShortId } from "../../lib/api-client.js"; import { ContextError } from "../../lib/errors.js"; -import { - formatProgressLine, - getProgressMessage, - truncateProgressMessage, -} from "../../lib/formatters/autofix.js"; +import { getProgressMessage } from "../../lib/formatters/seer.js"; import { isShortId } from "../../lib/issue-id.js"; +import { poll } from "../../lib/polling.js"; import { resolveOrg } from "../../lib/resolve-target.js"; -import { type AutofixState, isTerminalStatus } from "../../types/autofix.js"; import type { Writer } from "../../types/index.js"; +import { type AutofixState, isTerminalStatus } from "../../types/seer.js"; -/** Default polling interval in milliseconds */ -const DEFAULT_POLL_INTERVAL_MS = 1000; - -/** Animation interval for spinner updates (independent of polling) */ -const ANIMATION_INTERVAL_MS = 80; - -/** Default timeout in milliseconds (10 minutes) */ -const DEFAULT_TIMEOUT_MS = 600_000; +/** Default timeout in milliseconds (3 minutes) */ +const DEFAULT_TIMEOUT_MS = 180_000; type ResolvedIssue = { /** Resolved organization slug */ @@ -75,7 +66,7 @@ type PollAutofixOptions = { json: boolean; /** Polling interval in milliseconds (default: 1000) */ pollIntervalMs?: number; - /** Maximum time to wait in milliseconds (default: 600000 = 10 minutes) */ + /** Maximum time to wait in milliseconds (default: 180000 = 3 minutes) */ timeoutMs?: number; /** Custom timeout error message */ timeoutMessage?: string; @@ -85,6 +76,10 @@ type PollAutofixOptions = { /** * Check if polling should stop based on current state. + * + * @param state - Current autofix state + * @param stopOnWaitingForUser - Whether to stop on WAITING_FOR_USER_RESPONSE status + * @returns True if polling should stop */ function shouldStopPolling( state: AutofixState, @@ -101,8 +96,7 @@ function shouldStopPolling( /** * Poll autofix state until completion or timeout. - * Displays progress spinner and messages to stderr when not in JSON mode. - * Animation runs at 80ms intervals independently of polling frequency. + * Uses the generic poll utility with autofix-specific configuration. * * @param options - Polling configuration * @returns Final autofix state @@ -116,48 +110,21 @@ export async function pollAutofixState( issueId, stderr, json, - pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + pollIntervalMs, timeoutMs = DEFAULT_TIMEOUT_MS, - timeoutMessage = "Operation timed out after 10 minutes. Check the issue in Sentry web UI.", + timeoutMessage = "Operation timed out after 3 minutes. Try again or check the issue in Sentry web UI.", stopOnWaitingForUser = false, } = options; - const startTime = Date.now(); - let tick = 0; - let currentMessage = "Waiting for analysis to start..."; - - // Animation timer runs independently of polling for smooth spinner - let animationTimer: Timer | undefined; - if (!json) { - animationTimer = setInterval(() => { - const display = truncateProgressMessage(currentMessage); - stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`); - tick += 1; - }, ANIMATION_INTERVAL_MS); - } - - try { - while (Date.now() - startTime < timeoutMs) { - const state = await getAutofixState(orgSlug, issueId); - - if (state) { - // Update message for animation loop to display - currentMessage = getProgressMessage(state); - - if (shouldStopPolling(state, stopOnWaitingForUser)) { - return state; - } - } - - await Bun.sleep(pollIntervalMs); - } - - throw new Error(timeoutMessage); - } finally { - // Clean up animation timer - if (animationTimer) { - clearInterval(animationTimer); - stderr.write("\n"); - } - } + return await poll({ + fetchState: () => getAutofixState(orgSlug, issueId), + shouldStop: (state) => shouldStopPolling(state, stopOnWaitingForUser), + getProgressMessage, + stderr, + json, + pollIntervalMs, + timeoutMs, + timeoutMessage, + initialMessage: "Waiting for analysis to start...", + }); } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index ea1ba2ecd..2fd5ffc59 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -7,7 +7,6 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; -import type { AutofixResponse, AutofixState } from "../types/autofix.js"; import { type SentryEvent, SentryEventSchema, @@ -18,6 +17,7 @@ import { type SentryProject, SentryProjectSchema, } from "../types/index.js"; +import type { AutofixResponse, AutofixState } from "../types/seer.js"; import { refreshToken } from "./config.js"; import { ApiError } from "./errors.js"; diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index b07533b1c..1109687e5 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -5,8 +5,8 @@ * Re-exports all formatting utilities for CLI output. */ -export * from "./autofix.js"; export * from "./colors.js"; export * from "./human.js"; export * from "./json.js"; export * from "./output.js"; +export * from "./seer.js"; diff --git a/src/lib/formatters/autofix.ts b/src/lib/formatters/seer.ts similarity index 97% rename from src/lib/formatters/autofix.ts rename to src/lib/formatters/seer.ts index a561ae8e0..a3cf99cc8 100644 --- a/src/lib/formatters/autofix.ts +++ b/src/lib/formatters/seer.ts @@ -1,5 +1,5 @@ /** - * Autofix Output Formatters + * Seer Output Formatters * * Formatting utilities for Seer Autofix command output. */ @@ -9,7 +9,7 @@ import type { AutofixState, RootCause, SolutionArtifact, -} from "../../types/autofix.js"; +} from "../../types/seer.js"; import { cyan, green, muted, yellow } from "./colors.js"; const bold = (text: string): string => chalk.bold(text); @@ -22,6 +22,9 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", /** * Get a spinner frame for the given tick count. + * + * @param tick - Current animation tick (cycles through frames) + * @returns Single spinner character for display */ export function getSpinnerFrame(tick: number): string { const index = tick % SPINNER_FRAMES.length; @@ -157,6 +160,8 @@ export function formatRootCause(cause: RootCause, index: number): string[] { /** * Format the root cause analysis header. + * + * @returns Array of formatted header lines */ export function formatRootCauseHeader(): string[] { return ["", green("Root Cause Analysis Complete"), muted("═".repeat(30)), ""]; diff --git a/src/lib/polling.ts b/src/lib/polling.ts new file mode 100644 index 000000000..50926297c --- /dev/null +++ b/src/lib/polling.ts @@ -0,0 +1,123 @@ +/** + * Generic Polling Utility + * + * Provides a reusable polling mechanism with progress spinner display. + * Used by commands that need to wait for async operations to complete. + */ + +import type { Writer } from "../types/index.js"; +import { + formatProgressLine, + truncateProgressMessage, +} from "./formatters/seer.js"; + +/** Default polling interval in milliseconds */ +const DEFAULT_POLL_INTERVAL_MS = 1000; + +/** Animation interval for spinner updates (independent of polling) */ +const ANIMATION_INTERVAL_MS = 80; + +/** Default timeout in milliseconds (3 minutes) */ +const DEFAULT_TIMEOUT_MS = 180_000; + +/** + * Options for the generic poll function. + */ +export type PollOptions = { + /** Function to fetch current state */ + fetchState: () => Promise; + /** Predicate to determine if polling should stop */ + shouldStop: (state: T) => boolean; + /** Get progress message from state */ + getProgressMessage: (state: T) => string; + /** Output stream for progress */ + stderr: Writer; + /** Suppress progress output (JSON mode) */ + json?: boolean; + /** Poll interval in ms (default: 1000) */ + pollIntervalMs?: number; + /** Timeout in ms (default: 180000 / 3 min) */ + timeoutMs?: number; + /** Custom timeout message */ + timeoutMessage?: string; + /** Initial progress message */ + initialMessage?: string; +}; + +/** + * Generic polling function with animated progress display. + * + * Polls the fetchState function until shouldStop returns true or timeout is reached. + * Displays an animated spinner with progress messages when not in JSON mode. + * Animation runs at 80ms intervals independently of polling frequency. + * + * @typeParam T - The type of state being polled + * @param options - Polling configuration + * @returns The final state when shouldStop returns true + * @throws {Error} When timeout is reached before shouldStop returns true + * + * @example + * ```typescript + * const finalState = await poll({ + * fetchState: () => getAutofixState(org, issueId), + * shouldStop: (state) => isTerminalStatus(state.status), + * getProgressMessage: (state) => state.message ?? "Processing...", + * stderr: process.stderr, + * json: false, + * timeoutMs: 180_000, + * timeoutMessage: "Operation timed out after 3 minutes.", + * }); + * ``` + */ +export async function poll(options: PollOptions): Promise { + const { + fetchState, + shouldStop, + getProgressMessage, + stderr, + json = false, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + timeoutMs = DEFAULT_TIMEOUT_MS, + timeoutMessage = "Operation timed out after 3 minutes. Try again or check the Sentry web UI.", + initialMessage = "Waiting for operation to start...", + } = options; + + const startTime = Date.now(); + let tick = 0; + let currentMessage = initialMessage; + + // Animation timer runs independently of polling for smooth spinner + let animationTimer: Timer | undefined; + if (!json) { + animationTimer = setInterval(() => { + const display = truncateProgressMessage(currentMessage); + stderr.write(`\r\x1b[K${formatProgressLine(display, tick)}`); + tick += 1; + }, ANIMATION_INTERVAL_MS); + } + + try { + while (Date.now() - startTime < timeoutMs) { + const state = await fetchState(); + + if (state) { + // Update message for animation loop to display + currentMessage = getProgressMessage(state); + + if (shouldStop(state)) { + return state; + } + } + + await Bun.sleep(pollIntervalMs); + } + + throw new Error(timeoutMessage); + } finally { + // Clean up animation timer + if (animationTimer) { + clearInterval(animationTimer); + stderr.write("\n"); + } + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 0842fea86..3f68dffde 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,20 +7,6 @@ // DSN types export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; -export type { - AutofixResponse, - AutofixState, - RootCause, - SolutionArtifact, -} from "./autofix.js"; -// Autofix types -export { - extractRootCauses, - extractSolution, - isTerminalStatus, - SolutionArtifactSchema, - TERMINAL_STATUSES, -} from "./autofix.js"; // Configuration types export type { CachedProject, @@ -33,7 +19,6 @@ export { ProjectAliasesSchema, SentryConfigSchema, } from "./config.js"; - // OAuth types and schemas export type { DeviceCodeResponse, @@ -46,6 +31,20 @@ export { TokenErrorResponseSchema, TokenResponseSchema, } from "./oauth.js"; +export type { + AutofixResponse, + AutofixState, + RootCause, + SolutionArtifact, +} from "./seer.js"; +// Seer types +export { + extractRootCauses, + extractSolution, + isTerminalStatus, + SolutionArtifactSchema, + TERMINAL_STATUSES, +} from "./seer.js"; export type { // Breadcrumb types Breadcrumb, diff --git a/src/types/autofix.ts b/src/types/seer.ts similarity index 97% rename from src/types/autofix.ts rename to src/types/seer.ts index 573a9e686..776513bc6 100644 --- a/src/types/autofix.ts +++ b/src/types/seer.ts @@ -1,5 +1,5 @@ /** - * Autofix API Types + * Seer API Types * * Zod schemas and TypeScript types for Sentry's Seer Autofix API. */ @@ -162,7 +162,7 @@ export const PullRequestInfoSchema = z.object({ export type PullRequestInfo = z.infer; // ───────────────────────────────────────────────────────────────────────────── -// Solution Artifact (from fix command) +// Solution Artifact (from plan command) // ───────────────────────────────────────────────────────────────────────────── /** A single step in the solution plan */ @@ -263,14 +263,20 @@ export type AutofixUpdatePayload = // ───────────────────────────────────────────────────────────────────────────── /** - * Check if an autofix status is terminal (no more updates expected) + * Check if an autofix status is terminal (no more updates expected). + * + * @param status - The status string to check + * @returns True if the status indicates completion (COMPLETED, ERROR, CANCELLED) */ export function isTerminalStatus(status: string): boolean { return TERMINAL_STATUSES.includes(status as AutofixStatus); } /** - * Extract root causes from autofix steps + * Extract root causes from autofix state steps. + * + * @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) { diff --git a/test/lib/api-client.autofix.test.ts b/test/lib/api-client.seer.test.ts similarity index 97% rename from test/lib/api-client.autofix.test.ts rename to test/lib/api-client.seer.test.ts index dda9b9141..164a7be99 100644 --- a/test/lib/api-client.autofix.test.ts +++ b/test/lib/api-client.seer.test.ts @@ -1,7 +1,7 @@ /** - * Autofix API Client Tests + * Seer API Client Tests * - * Tests for the autofix-related API functions by mocking fetch. + * Tests for the seer-related API functions by mocking fetch. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -21,7 +21,7 @@ let originalFetch: typeof globalThis.fetch; beforeEach(async () => { testConfigDir = join( process.env.SENTRY_CLI_CONFIG_DIR ?? "/tmp", - `test-autofix-api-${Math.random().toString(36).slice(2)}` + `test-seer-api-${Math.random().toString(36).slice(2)}` ); mkdirSync(testConfigDir, { recursive: true }); process.env.SENTRY_CLI_CONFIG_DIR = testConfigDir; diff --git a/test/lib/formatters/autofix.test.ts b/test/lib/formatters/seer.test.ts similarity index 97% rename from test/lib/formatters/autofix.test.ts rename to test/lib/formatters/seer.test.ts index 4cf2431d2..ba4f031ac 100644 --- a/test/lib/formatters/autofix.test.ts +++ b/test/lib/formatters/seer.test.ts @@ -1,7 +1,7 @@ /** - * Autofix Formatter Tests + * Seer Formatter Tests * - * Tests for formatting functions in src/lib/formatters/autofix.ts + * Tests for formatting functions in src/lib/formatters/seer.ts */ import { describe, expect, test } from "bun:test"; @@ -13,8 +13,8 @@ import { getProgressMessage, getSpinnerFrame, truncateProgressMessage, -} from "../../../src/lib/formatters/autofix.js"; -import type { AutofixState, RootCause } from "../../../src/types/autofix.js"; +} from "../../../src/lib/formatters/seer.js"; +import type { AutofixState, RootCause } from "../../../src/types/seer.js"; describe("getSpinnerFrame", () => { test("returns a spinner character", () => { diff --git a/test/types/autofix.test.ts b/test/types/seer.test.ts similarity index 96% rename from test/types/autofix.test.ts rename to test/types/seer.test.ts index 2fd09a587..0746b8587 100644 --- a/test/types/autofix.test.ts +++ b/test/types/seer.test.ts @@ -1,7 +1,7 @@ /** - * Autofix Type Helper Tests + * Seer Type Helper Tests * - * Tests for pure functions in src/types/autofix.ts + * Tests for pure functions in src/types/seer.ts */ import { describe, expect, test } from "bun:test"; @@ -10,7 +10,7 @@ import { extractRootCauses, isTerminalStatus, TERMINAL_STATUSES, -} from "../../src/types/autofix.js"; +} from "../../src/types/seer.js"; describe("isTerminalStatus", () => { test("returns true for COMPLETED status", () => { From 47099d0b534840763c5cb7ac15aa10fd7aac74ef Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 26 Jan 2026 22:04:52 +0530 Subject: [PATCH 19/19] fix: minor change --- src/commands/issue/explain.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 6b395e7a8..1acf63dad 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -100,9 +100,9 @@ export const explainCommand = buildCommand({ // 2. Trigger new analysis if none exists or forced if (!state) { if (!flags.json) { - const message = flags.force - ? "Forcing fresh root cause analysis...\n" - : "Starting root cause analysis...\n"; + let message = flags.force ? "Forcing fresh" : "Starting"; + + message += " root cause analysis, it can take several minutes...\n"; stderr.write(message); } await triggerRootCauseAnalysis(org, numericId);