Skip to content
121 changes: 106 additions & 15 deletions src/commands/issue/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
import { logger } from "../../lib/logger.js";
import {
type AutofixState,
extractExaminedFiles,
extractNoSolutionReason,
extractRootCauses,
extractSolution,
type RootCause,
Expand Down Expand Up @@ -107,39 +109,119 @@ function validateCauseSelection(
return causeId;
}

/** Context about why no solution was produced */
type NoSolutionContext = {
/** Seer's reason for not producing a solution (from the artifact) */
reason?: string;
/** Root cause description that was analyzed */
root_cause?: string;
/** Files Seer examined during analysis */
files_examined?: string[];
};

/** Return type for issue plan — includes state metadata and solution data */
type PlanData = {
run_id: number;
status: string;
/** The solution data (without the artifact wrapper). Null when no solution is available. */
solution: SolutionArtifact["data"] | null;
/** Context about why no solution was produced. Only present when solution is null. */
no_solution_context?: NoSolutionContext;
};

/**
* Format solution plan data for human-readable terminal output.
*
* Returns the formatted solution or a "no solution" message.
* Returns the formatted solution, or a contextual message explaining
* why no solution was produced.
*/
function formatPlanOutput(data: PlanData): string {
if (data.solution) {
return formatSolution({ key: "solution", data: data.solution });
}
return "No solution found. Check the Sentry web UI for details.";

const lines: string[] = [];
const ctx = data.no_solution_context;

if (ctx?.reason) {
lines.push(`No solution found: ${ctx.reason}`);
} else {
lines.push(
"No solution found. Seer completed analysis but could not identify a code fix."
);
if (ctx?.root_cause) {
lines.push("");
lines.push(`Root cause analyzed: ${ctx.root_cause}`);
}
}

if (ctx?.files_examined && ctx.files_examined.length > 0) {
lines.push("");
lines.push("Files examined:");
for (const file of ctx.files_examined) {
lines.push(` ${file}`);
}
}

return lines.join("\n");
}

/**
* Gather context about why Seer produced no solution.
*
* Returns undefined when there's nothing useful to report.
*/
function buildNoSolutionContext(
state: AutofixState,
selectedCause?: RootCause
): NoSolutionContext | undefined {
const reason = extractNoSolutionReason(state);
const cause = selectedCause ?? extractRootCauses(state)[0];
Comment thread
sentry[bot] marked this conversation as resolved.
const files = cause ? extractExaminedFiles([cause]) : [];

if (!(reason || cause?.description) && files.length === 0) {
return;
}

const ctx: NoSolutionContext = {};
if (reason) {
ctx.reason = reason;
}
if (cause?.description) {
ctx.root_cause = cause.description;
}
if (files.length > 0) {
ctx.files_examined = files;
}
return ctx;
}

/**
* Build the plan data object from autofix state.
*
* Stores `solution.data` (not the full artifact) to keep the JSON shape flat —
* consumers get `{ run_id, status, solution: { one_line_summary, steps, ... } }`.
*
* When no solution is available, includes context about why (reason from the
* API, root cause description, and files examined) so the user isn't left
* with a bare "no solution found" message.
*/
function buildPlanData(state: AutofixState): PlanData {
function buildPlanData(
state: AutofixState,
selectedCause?: RootCause
): PlanData {
const solution = extractSolution(state);
return {
const data: PlanData = {
run_id: state.run_id,
status: state.status,
solution: solution?.data ?? null,
};

if (!solution) {
data.no_solution_context = buildNoSolutionContext(state, selectedCause);
}

return data;
}

export const planCommand = buildCommand({
Expand Down Expand Up @@ -217,34 +299,43 @@ export const planCommand = buildCommand({
// Validate we have root causes
const causes = validateRootCauses(state);

// Validate cause selection
const causeId = validateCauseSelection(causes, flags.cause, issueArg);
const selectedCause = causes[causeId];
// Validate cause selection (always returns a valid index into causes)
const causeIndex = validateCauseSelection(causes, flags.cause, issueArg);
const selectedCause = causes.at(causeIndex);
if (!selectedCause) {
throw new ValidationError(
`Invalid cause index: ${causeIndex}. Valid range is 0-${causes.length - 1}.`
);
}

// Check if solution already exists (skip if --force)
if (!flags.force) {
const existingSolution = extractSolution(state);
if (existingSolution) {
return yield new CommandOutput(buildPlanData(state));
return yield new CommandOutput(buildPlanData(state, selectedCause));
}
}

// No solution exists, trigger planning
if (!flags.json) {
const log = logger.withTag("issue.plan");
log.info(`Creating plan for cause #${causeId}...`);
if (selectedCause) {
log.info(`"${selectedCause.description}"`);
}
log.info(`Creating plan for cause #${causeIndex}...`);
log.info(`"${selectedCause.description}"`);
}

await triggerSolutionPlanning(org, numericId, state.run_id);
await triggerSolutionPlanning(
org,
numericId,
state.run_id,
selectedCause.id
);

// Poll until PR is created
// Poll until solution is ready (NEED_MORE_INFORMATION) or terminal
const finalState = await pollAutofixState({
orgSlug: org,
issueId: numericId,
json: flags.json,
stopOnWaitingForUser: true,
timeoutMessage:
"Plan creation timed out after 6 minutes. Try again or check the issue in Sentry web UI.",
timeoutHint:
Expand All @@ -263,7 +354,7 @@ export const planCommand = buildCommand({
throw new Error("Plan creation was cancelled.");
}

return yield new CommandOutput(buildPlanData(finalState));
return yield new CommandOutput(buildPlanData(finalState, selectedCause));
} catch (error) {
// Handle API errors with friendly messages
if (error instanceof ApiError) {
Expand Down
13 changes: 11 additions & 2 deletions src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,8 +927,13 @@ export async function ensureRootCauseAnalysis(
/**
* Check if polling should stop based on current state.
*
* Terminal statuses (COMPLETED, ERROR, CANCELLED) always stop polling.
* When `stopOnWaitingForUser` is true, also stops on interactive statuses:
* - `WAITING_FOR_USER_RESPONSE` — root cause analysis needs user input
* - `NEED_MORE_INFORMATION` — solution step completed, awaiting user decision
*
* @param state - Current autofix state
* @param stopOnWaitingForUser - Whether to stop on WAITING_FOR_USER_RESPONSE status
* @param stopOnWaitingForUser - Whether to stop on interactive statuses
* @returns True if polling should stop
*/
function shouldStopPolling(
Expand All @@ -938,7 +943,11 @@ function shouldStopPolling(
if (isTerminalStatus(state.status)) {
return true;
}
if (stopOnWaitingForUser && state.status === "WAITING_FOR_USER_RESPONSE") {
if (
stopOnWaitingForUser &&
(state.status === "WAITING_FOR_USER_RESPONSE" ||
state.status === "NEED_MORE_INFORMATION")
) {
return true;
}
return false;
Expand Down
17 changes: 13 additions & 4 deletions src/lib/api/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,28 +76,37 @@ export async function getAutofixState(

/**
* Trigger solution planning for an existing autofix run.
* Uses region-aware routing for multi-region support.
*
* Sends a `select_root_cause` update with `stopping_point: "solution"` to the
* autofix update endpoint. This tells Seer to proceed from root cause analysis
* to generating a solution plan.
*
* @param orgSlug - The organization slug
* @param issueId - The numeric Sentry issue ID
* @param runId - The autofix run ID
* @param causeId - The root cause ID to plan a solution for
* @returns The response from the API
*/
export async function triggerSolutionPlanning(
orgSlug: string,
issueId: string,
runId: number
runId: number,
causeId: number
): Promise<unknown> {
const regionUrl = await resolveOrgRegion(orgSlug);

const { data } = await apiRequestToRegion(
regionUrl,
`/organizations/${orgSlug}/issues/${issueId}/autofix/`,
`/organizations/${orgSlug}/issues/${issueId}/autofix/update/`,
{
method: "POST",
body: {
run_id: runId,
step: "solution",
payload: {
type: "select_root_cause",
cause_id: causeId,
stopping_point: "solution",
},
},
}
);
Expand Down
Loading
Loading