diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index f5595fed0b..4a6db7edea 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -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, @@ -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 { + 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." + ); +} + export const planCommand = buildCommand({ docs: { brief: "Generate a solution plan using Seer AI", @@ -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") { + await confirmRootCauseAndContinue(state, flags.json); + } + // Check if solution already exists (skip if --force) if (!flags.force) { const existingSolution = extractSolution(state); @@ -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) { diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index c45db5dc66..36648a9922 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -35,12 +35,15 @@ type MonitorWithOrg = SentryMonitor & { orgSlug?: string }; * Interval schedules show `"every "` (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; diff --git a/packages/cli/src/lib/api/seer.ts b/packages/cli/src/lib/api/seer.ts index 5bbb6c3467..8779d86733 100644 --- a/packages/cli/src/lib/api/seer.ts +++ b/packages/cli/src/lib/api/seer.ts @@ -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"; diff --git a/packages/cli/test/commands/issue/plan.func.test.ts b/packages/cli/test/commands/issue/plan.func.test.ts new file mode 100644 index 0000000000..1c93a5ae50 --- /dev/null +++ b/packages/cli/test/commands/issue/plan.func.test.ts @@ -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) { + const func = await planCommand.loader(); + await func.call(context, PLAN_FLAGS, "IOS-1"); +} + +describe("issue plan waiting-for-user-response", () => { + let resolveSpy: ReturnType; + let analyzeSpy: ReturnType; + let promptSpy: ReturnType; + + 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); + 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; + let analyzeSpy: ReturnType; + let pollSpy: ReturnType; + + 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(); + }); +}); diff --git a/packages/cli/test/commands/monitor/list.test.ts b/packages/cli/test/commands/monitor/list.test.ts new file mode 100644 index 0000000000..c487e65d65 --- /dev/null +++ b/packages/cli/test/commands/monitor/list.test.ts @@ -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(""); + }); +}); diff --git a/packages/cli/test/lib/api-client.seer.test.ts b/packages/cli/test/lib/api-client.seer.test.ts index d76a69b589..abf9a263c7 100644 --- a/packages/cli/test/lib/api-client.seer.test.ts +++ b/packages/cli/test/lib/api-client.seer.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { normalizeAgentStatus } from "../../src/lib/api/seer.js"; import { getAutofixState, triggerRootCauseAnalysis, @@ -360,3 +361,29 @@ describe("triggerSolutionPlanning", () => { }); }); }); + +describe("normalizeAgentStatus", () => { + test.each([ + ["processing", "PROCESSING"], + ["completed", "COMPLETED"], + ["error", "ERROR"], + ["canceled", "CANCELLED"], + ["cancelled", "CANCELLED"], + ["awaiting_user_input", "WAITING_FOR_USER_RESPONSE"], + ["need_more_information", "NEED_MORE_INFORMATION"], + ])("maps %s to %s", (input, expected) => { + expect(normalizeAgentStatus(input)).toBe(expected); + }); + + test.each([ + [null], + [undefined], + [""], + ])("defaults %s status to PROCESSING", (input) => { + expect(normalizeAgentStatus(input)).toBe("PROCESSING"); + }); + + test("uppercases unknown statuses", () => { + expect(normalizeAgentStatus("some_new_status")).toBe("SOME_NEW_STATUS"); + }); +});