Skip to content
59 changes: 58 additions & 1 deletion packages/cli/src/commands/issue/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
* Automatically runs root cause analysis if not already done.
*/

import { isatty } from "node:tty";
import type { SentryContext } from "../../context.js";
import { triggerSolutionPlanning } from "../../lib/api-client.js";
import { buildCommand } from "../../lib/command.js";
import { ApiError } from "../../lib/errors.js";
import { ApiError, CliError } from "../../lib/errors.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
formatSolution,
Expand Down Expand Up @@ -154,6 +155,48 @@ function buildPlanData(state: AutofixState): PlanData {
return data;
}

/**
* Root cause analysis paused waiting for the user to confirm the identified root
* cause before solution planning. When the session is interactive (a TTY and not
* JSON output), show the root cause and ask whether to continue; the caller then
* advances the run via {@link triggerSolutionPlanning}, which is how the Sentry
* UI proceeds from this state.
*
* The Sentry API has no endpoint to submit a specific `cause_id` from the CLI
* (selecting among candidates happens through the web UI's interactive run
* state), so this is a confirm-to-continue rather than a picker. Throws a
* {@link CliError} pointing at the Sentry UI when we cannot prompt (JSON mode,
* non-TTY, no root cause) or the user declines.
*/
async function confirmRootCauseAndContinue(
state: AutofixState,
json: boolean
): Promise<void> {
const causes = json ? [] : extractRootCauses(state);
const primaryCause = causes[0];

if (isatty(0) && isatty(2) && primaryCause) {
const log = logger.withTag("issue.plan");
log.info("Root cause identified:");
log.info(`"${primaryCause.description}"`);

const proceed = await log.prompt("Generate a solution plan for it?", {
type: "confirm",
initial: true,
});

// consola returns a non-boolean (Symbol(clack:cancel)) when cancelled.
if (proceed === true) {
return;
}
}

throw new CliError(
"Root cause analysis requires your input before a plan can be generated.\n" +
"Open the issue in Sentry to select a root cause, then re-run this command."
);
}
Comment thread
jared-outpost[bot] marked this conversation as resolved.

export const planCommand = buildCommand({
docs: {
brief: "Generate a solution plan using Seer AI",
Expand Down Expand Up @@ -217,6 +260,13 @@ export const planCommand = buildCommand({
json: flags.json,
});

// Root cause analysis can pause waiting for the user to confirm the
// root cause before planning. When running interactively, confirm in the
// terminal and continue; otherwise point them at the Sentry UI.
if (state.status === "WAITING_FOR_USER_RESPONSE") {
Comment thread
jared-outpost[bot] marked this conversation as resolved.
await confirmRootCauseAndContinue(state, flags.json);
}

// Check if solution already exists (skip if --force)
if (!flags.force) {
const existingSolution = extractSolution(state);
Expand Down Expand Up @@ -261,6 +311,13 @@ export const planCommand = buildCommand({
throw new Error("Plan creation was cancelled.");
}

if (finalState.status === "WAITING_FOR_USER_RESPONSE") {
throw new CliError(
"Plan creation requires your input in the Sentry UI.\n" +
"Open the issue in Sentry to provide the requested information, then re-run this command."
);
}

return yield new CommandOutput(buildPlanData(finalState));
} catch (error) {
if (error instanceof ApiError) {
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/monitor/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ type MonitorWithOrg = SentryMonitor & { orgSlug?: string };
* Interval schedules show `"every <value> <unit>"` (e.g. `"every 1 hour"`).
* Returns an empty string when no schedule is configured.
*/
function formatSchedule(monitor: MonitorWithOrg): string {
export function formatSchedule(monitor: MonitorWithOrg): string {
const config = monitor.config;
if (!config?.schedule) {
return "";
}
if (Array.isArray(config.schedule)) {
if (config.schedule.length < 2) {
return String(config.schedule[0] ?? "");
}
return `every ${config.schedule[0]} ${config.schedule[1]}`;
}
return config.schedule;
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/lib/api/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ const EXPLORER_MODE_PARAMS = { mode: "explorer" };
* returns false and polling spins until timeout. `awaiting_user_input` maps to
* `WAITING_FOR_USER_RESPONSE`.
*/
function normalizeAgentStatus(status: string): string {
export function normalizeAgentStatus(
status: string | null | undefined
): string {
if (!status) {
return "PROCESSING";
}
switch (status) {
case "processing":
return "PROCESSING";
Expand Down
232 changes: 232 additions & 0 deletions packages/cli/test/commands/issue/plan.func.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Tests for the `issue plan` command's handling of the
* WAITING_FOR_USER_RESPONSE root-cause state.
*/

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../../src/commands/issue/utils.js")
>();
return Object.fromEntries(
Object.entries(actual).map(([key, value]) => [
key,
typeof value === "function" ? vi.fn(value) : value,
])
);
});

import { isatty } from "node:tty";
import { planCommand } from "../../../src/commands/issue/plan.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as issueUtils from "../../../src/commands/issue/utils.js";
import { triggerSolutionPlanning } from "../../../src/lib/api-client.js";
import { CliError } from "../../../src/lib/errors.js";
import { logger } from "../../../src/lib/logger.js";
import type { AutofixState, RootCause } from "../../../src/types/seer.js";

vi.mock("node:tty", () => ({ isatty: vi.fn() }));

vi.mock("../../../src/lib/api-client.js", () => ({
triggerSolutionPlanning: vi.fn(async () => ({})),
}));

const PLAN_FLAGS = { json: false, force: false, fresh: false } as const;

function sampleCause(id: number, description: string): RootCause {
return { id, description };
}

function waitingState(causes: RootCause[]): AutofixState {
return {
status: "WAITING_FOR_USER_RESPONSE",
run_id: 42,
steps: [
{
id: "root-cause",
key: "root_cause_analysis",
status: "WAITING_FOR_USER_RESPONSE",
title: "Root cause",
causes,
},
],
};
}

function createMockContext() {
return {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
cwd: "/tmp",
};
}

async function runPlan(context: ReturnType<typeof createMockContext>) {
const func = await planCommand.loader();
await func.call(context, PLAN_FLAGS, "IOS-1");
}

describe("issue plan waiting-for-user-response", () => {
let resolveSpy: ReturnType<typeof vi.spyOn>;
let analyzeSpy: ReturnType<typeof vi.spyOn>;
let promptSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.clearAllMocks();
resolveSpy = vi.spyOn(issueUtils, "resolveOrgAndIssueId");
analyzeSpy = vi.spyOn(issueUtils, "ensureRootCauseAnalysis");
promptSpy = vi.fn();
// The command prompts through a tagged logger, so intercept withTag.
vi.spyOn(logger, "withTag").mockReturnValue({
info: vi.fn(),
prompt: promptSpy,
} as unknown as ReturnType<typeof logger.withTag>);
resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" });
});

afterEach(() => {
vi.restoreAllMocks();
});

test("throws a UI hint when not interactive", async () => {
vi.mocked(isatty).mockReturnValue(false);
analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")]));

await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError);
expect(promptSpy).not.toHaveBeenCalled();
});

test("confirms the root cause and continues to planning when interactive", async () => {
vi.mocked(isatty).mockReturnValue(true);
analyzeSpy.mockResolvedValue(
waitingState([sampleCause(0, "First"), sampleCause(1, "Second")])
);
promptSpy.mockResolvedValue(true);
const solutionSpy = vi
.spyOn(issueUtils, "pollAutofixState")
.mockResolvedValue({ status: "COMPLETED", run_id: 42, steps: [] });

await runPlan(createMockContext());

expect(promptSpy).toHaveBeenCalledWith(
"Generate a solution plan for it?",
expect.objectContaining({ type: "confirm" })
);
expect(triggerSolutionPlanning).toHaveBeenCalledWith("test-org", "1", 42);
expect(solutionSpy).toHaveBeenCalled();
});

test("throws when the user declines to continue", async () => {
vi.mocked(isatty).mockReturnValue(true);
analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")]));
promptSpy.mockResolvedValue(false);

await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError);
expect(triggerSolutionPlanning).not.toHaveBeenCalled();
});

test("throws when the user cancels the prompt", async () => {
vi.mocked(isatty).mockReturnValue(true);
analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")]));
// consola returns a cancel symbol (non-boolean) when cancelled.
promptSpy.mockResolvedValue(Symbol("clack:cancel"));

await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError);
expect(triggerSolutionPlanning).not.toHaveBeenCalled();
});
});

function completedState(): AutofixState {
return { status: "COMPLETED", run_id: 42, steps: [] };
}

describe("issue plan after solution planning", () => {
let resolveSpy: ReturnType<typeof vi.spyOn>;
let analyzeSpy: ReturnType<typeof vi.spyOn>;
let pollSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isatty).mockReturnValue(false);
resolveSpy = vi.spyOn(issueUtils, "resolveOrgAndIssueId");
analyzeSpy = vi.spyOn(issueUtils, "ensureRootCauseAnalysis");
pollSpy = vi.spyOn(issueUtils, "pollAutofixState");
resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" });
analyzeSpy.mockResolvedValue(completedState());
});

afterEach(() => {
vi.restoreAllMocks();
});

test("returns an existing solution without triggering planning", async () => {
analyzeSpy.mockResolvedValue({
status: "COMPLETED",
run_id: 42,
steps: [
{
id: "solution",
key: "solution",
status: "COMPLETED",
title: "Solution",
solution: [{ title: "Fix it", code_snippet_and_analysis: "do x" }],
},
],
} as AutofixState);

await runPlan(createMockContext());

expect(triggerSolutionPlanning).not.toHaveBeenCalled();
expect(pollSpy).not.toHaveBeenCalled();
});

test("throws when planning ends in ERROR", async () => {
pollSpy.mockResolvedValue({ status: "ERROR", run_id: 42, steps: [] });

await expect(runPlan(createMockContext())).rejects.toThrow(
/Plan creation failed/
);
});

test("throws when planning ends in CANCELLED", async () => {
pollSpy.mockResolvedValue({ status: "CANCELLED", run_id: 42, steps: [] });

await expect(runPlan(createMockContext())).rejects.toThrow(
/Plan creation was cancelled/
);
});

test("throws a UI hint when planning still needs user input", async () => {
pollSpy.mockResolvedValue({
status: "WAITING_FOR_USER_RESPONSE",
run_id: 42,
steps: [],
});

await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError);
});

test("emits the plan when planning completes", async () => {
pollSpy.mockResolvedValue({
status: "COMPLETED",
run_id: 42,
steps: [
{
id: "solution",
key: "solution",
status: "COMPLETED",
title: "Solution",
solution: [{ title: "Fix it", code_snippet_and_analysis: "do x" }],
},
],
} as AutofixState);

const context = createMockContext();
await runPlan(context);

expect(triggerSolutionPlanning).toHaveBeenCalledWith("test-org", "1", 42);
expect(pollSpy).toHaveBeenCalled();
});
});
44 changes: 44 additions & 0 deletions packages/cli/test/commands/monitor/list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Tests for monitor list schedule formatting.
*/

import { describe, expect, test } from "vitest";
import { formatSchedule } from "../../../src/commands/monitor/list.js";
import type { SentryMonitor } from "../../../src/types/index.js";

function monitor(config: SentryMonitor["config"]): SentryMonitor {
return {
id: "1",
slug: "m",
name: "Monitor",
status: "active",
config,
};
}

describe("formatSchedule", () => {
test("returns an empty string when no schedule is configured", () => {
expect(formatSchedule(monitor(undefined))).toBe("");
expect(formatSchedule(monitor({}))).toBe("");
});

test("renders a crontab schedule verbatim", () => {
expect(formatSchedule(monitor({ schedule: "0 * * * *" }))).toBe(
"0 * * * *"
);
});

test("renders a well-formed interval schedule", () => {
expect(formatSchedule(monitor({ schedule: [1, "hour"] }))).toBe(
"every 1 hour"
);
});

test("falls back to the first element for a single-element interval", () => {
expect(formatSchedule(monitor({ schedule: [5] }))).toBe("5");
});

test("returns an empty string for an empty interval array", () => {
expect(formatSchedule(monitor({ schedule: [] }))).toBe("");
});
});
Loading
Loading