Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Generate a solution plan using Seer AI
**Flags:**
- `--cause <value> - Root cause ID to plan (required if multiple causes exist)`
- `--json - Output as JSON`
- `--force - Force new plan even if one exists`

**Examples:**

Expand Down
48 changes: 11 additions & 37 deletions src/commands/issue/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@

import { buildCommand } from "@stricli/core";
import type { SentryContext } from "../../context.js";
import {
getAutofixState,
triggerRootCauseAnalysis,
} from "../../lib/api-client.js";
import { ApiError } from "../../lib/errors.js";
import { writeFooter, writeJson } from "../../lib/formatters/index.js";
import {
Expand All @@ -18,8 +14,8 @@ import {
} from "../../lib/formatters/seer.js";
import { extractRootCauses } from "../../types/seer.js";
import {
ensureRootCauseAnalysis,
issueIdPositional,
pollAutofixState,
resolveOrgAndIssueId,
} from "./utils.js";

Expand Down Expand Up @@ -86,38 +82,16 @@ export const explainCommand = buildCommand({
});
resolvedOrg = org;

// 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") {
stderr.write("Root cause analysis failed, retrying...\n");
state = null;
}

// 2. Trigger new analysis if none exists or forced
if (!state) {
if (!flags.json) {
const prefix = flags.force ? "Forcing fresh" : "Starting";
stderr.write(
`${prefix} root cause analysis, it can take several minutes...\n`
);
}
await triggerRootCauseAnalysis(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,
});
}
// Ensure root cause analysis exists (triggers if needed)
const state = await ensureRootCauseAnalysis({
org,
issueId: numericId,
stderr,
json: flags.json,
force: flags.force,
});

// 4. Extract root causes from steps
// Extract root causes from steps
const causes = extractRootCauses(state);
if (causes.length === 0) {
throw new Error(
Comment on lines +87 to 97
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The refactoring removed explicit handling for the ERROR status from ensureRootCauseAnalysis, leading to misleading error messages when the analysis fails.
Severity: HIGH

Suggested Fix

Reintroduce a check for state.status === "ERROR" after the ensureRootCauseAnalysis call. If the status is ERROR, throw a specific error indicating that the root cause analysis failed, similar to the logic that was removed.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/commands/issue/explain.ts#L82-L97

Potential issue: The `ensureRootCauseAnalysis` function can return a state with `status:
"ERROR"`. The refactored code in `explain.ts` no longer checks for this status. Instead,
it calls `extractRootCauses`, which returns an empty array for a failed analysis. This
triggers an incorrect error message, "Analysis completed but no root causes found,"
which misleads the user into thinking the analysis succeeded but was fruitless, when in
fact it failed. The previous implementation correctly handled this `ERROR` state.

Did we get this right? 👍 / 👎 to inform future reviews.

Expand All @@ -126,7 +100,7 @@ export const explainCommand = buildCommand({
);
}

// 5. Output results
// Output results
if (flags.json) {
writeJson(stdout, causes);
return;
Expand Down
160 changes: 88 additions & 72 deletions src/commands/issue/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,29 @@
* sentry issue plan
*
* Generate a solution plan for a Sentry issue using Seer AI.
* Requires that 'sentry issue explain' has been run first.
* Automatically runs root cause analysis if not already done.
*/

import { buildCommand, numberParser } from "@stricli/core";
import type { SentryContext } from "../../context.js";
import {
getAutofixState,
triggerSolutionPlanning,
} from "../../lib/api-client.js";
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 {
formatSolution,
handleSeerApiError,
} from "../../lib/formatters/seer.js";
import type { Writer } from "../../types/index.js";
import {
type AutofixState,
extractRootCauses,
extractSolution,
type RootCause,
type SolutionArtifact,
} from "../../types/seer.js";
import {
ensureRootCauseAnalysis,
issueIdPositional,
pollAutofixState,
resolveOrgAndIssueId,
Expand All @@ -33,52 +33,24 @@ import {
type PlanFlags = {
readonly cause?: number;
readonly json: boolean;
readonly force: boolean;
};

/**
* Validate that an autofix run exists and has completed root cause analysis.
* Validate that the autofix state has root causes identified.
*
* @param state - Current autofix state
* @param issueId - Issue ID for error messages
* @returns The validated state and root causes
* @param state - Current autofix state (already ensured to exist)
* @returns Array of root causes
* @throws {ValidationError} If no root causes found
*/
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 plan: autofix is in '${state.status}' state.`
);
}

function validateRootCauses(state: AutofixState): RootCause[] {
const causes = extractRootCauses(state);
if (causes.length === 0) {
throw new ValidationError(
"No root causes identified. Cannot create a plan without a root cause."
);
}

return { state, causes };
return causes;
}

/**
Expand Down Expand Up @@ -129,15 +101,46 @@ function validateCauseSelection(
return causeId;
}

type OutputSolutionOptions = {
stdout: Writer;
stderr: Writer;
solution: SolutionArtifact | null;
state: AutofixState;
json: boolean;
};

/**
* Output a solution artifact to stdout.
*/
function outputSolution(options: OutputSolutionOptions): void {
const { stdout, stderr, solution, state, json } = options;

if (json) {
writeJson(stdout, {
run_id: state.run_id,
status: state.status,
solution: solution?.data ?? null,
});
return;
}

if (solution) {
const lines = formatSolution(solution);
stdout.write(`${lines.join("\n")}\n`);
} else {
stderr.write("No solution found. Check the Sentry web UI for details.\n");
}
}

export const planCommand = buildCommand({
docs: {
brief: "Generate a solution plan using Seer AI",
fullDescription:
"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 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" +
"This command automatically runs root cause analysis if needed, then " +
"generates a solution plan with specific implementation steps to fix the issue.\n\n" +
"If multiple root causes are identified, use --cause to specify which one.\n" +
"Use --force to regenerate a plan even if one already exists.\n\n" +
"Issue formats:\n" +
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
" <project>-suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" +
Expand All @@ -150,7 +153,8 @@ export const planCommand = buildCommand({
"Examples:\n" +
" sentry issue plan 123456789 --cause 0\n" +
" sentry issue plan sentry/EXTENSION-7 --cause 1\n" +
" sentry issue plan cli-G --cause 0",
" sentry issue plan cli-G --cause 0\n" +
" sentry issue plan 123456789 --force",
},
parameters: {
positional: issueIdPositional,
Expand All @@ -166,6 +170,11 @@ export const planCommand = buildCommand({
brief: "Output as JSON",
default: false,
},
force: {
kind: "boolean",
brief: "Force new plan even if one exists",
default: false,
},
},
},
async func(
Expand All @@ -187,24 +196,44 @@ export const planCommand = buildCommand({
});
resolvedOrg = org;

// Get current autofix state
const currentState = await getAutofixState(org, numericId);
// Ensure root cause analysis exists (runs explain if needed)
const state = await ensureRootCauseAnalysis({
org,
issueId: numericId,
stderr,
json: flags.json,
});

// Validate we have a completed root cause analysis
const { state, causes } = validateAutofixState(currentState, issueArg);
// Validate we have root causes
const causes = validateRootCauses(state);

// Validate cause selection
const causeId = validateCauseSelection(causes, flags.cause, issueArg);
const selectedCause = causes[causeId];

// Check if solution already exists (skip if --force)
if (!flags.force) {
const existingSolution = extractSolution(state);
if (existingSolution) {
outputSolution({
stdout,
stderr,
solution: existingSolution,
state,
json: flags.json,
});
return;
}
}

// No solution exists, trigger planning
if (!flags.json) {
stderr.write(`Creating plan for cause #${causeId}...\n`);
if (selectedCause) {
stderr.write(`${muted(`"${selectedCause.description}"`)}\n\n`);
}
}

// Trigger solution planning to generate implementation steps
await triggerSolutionPlanning(org, numericId, state.run_id);

// Poll until PR is created
Expand All @@ -214,7 +243,7 @@ export const planCommand = buildCommand({
stderr,
json: flags.json,
timeoutMessage:
"Plan creation timed out after 3 minutes. Try again or check the issue in Sentry web UI.",
"Plan creation timed out after 6 minutes. Try again or check the issue in Sentry web UI.",
});

// Handle errors
Expand All @@ -228,28 +257,15 @@ export const planCommand = buildCommand({
throw new Error("Plan creation was cancelled.");
}

// Extract solution artifact
// Extract and output solution
const solution = extractSolution(finalState);

// Output results
if (flags.json) {
writeJson(stdout, {
run_id: finalState.run_id,
status: finalState.status,
solution: solution?.data ?? null,
});
return;
}

// Human-readable output
if (solution) {
const lines = formatSolution(solution);
stdout.write(`${lines.join("\n")}\n`);
} else {
stderr.write(
"No solution found. Check the Sentry web UI for details.\n"
);
}
outputSolution({
stdout,
stderr,
solution,
state: finalState,
json: flags.json,
});
} catch (error) {
// Handle API errors with friendly messages
if (error instanceof ApiError) {
Expand Down
Loading
Loading