From b9bf4c8d01852cc7b344556d19a706728344a1ef Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Tue, 10 Nov 2026 13:39:23 -0600 Subject: [PATCH 1/3] feat(cli): Tell users when Seer is out of credits issue explain and issue plan now say the organization is out of Seer credits when Sentry answers 402 with code seer_out_of_credits, or when a run stops with failure_reason out_of_credits. Before, the 402 read as "Seer requires a paid plan" with a free trial offer, and a stopped run read as an analysis with no results. Fixes CW-3075 --- apps/cli-docs/src/content/docs/exit-codes.md | 1 + packages/cli/AGENTS.md | 2 +- packages/cli/src/commands/issue/explain.ts | 7 ++++- packages/cli/src/commands/issue/plan.ts | 12 ++++++-- packages/cli/src/commands/issue/utils.ts | 6 ++++ packages/cli/src/lib/errors.ts | 15 +++++++++- packages/cli/src/lib/formatters/seer.ts | 18 +++++++++-- packages/cli/src/types/seer.ts | 2 ++ .../cli/test/commands/issue/plan.func.test.ts | 14 +++++++++ .../cli/test/commands/issue/utils.test.ts | 30 +++++++++++++++++++ packages/cli/test/lib/errors.test.ts | 11 +++++++ packages/cli/test/lib/formatters/seer.test.ts | 10 +++++++ 12 files changed, 121 insertions(+), 7 deletions(-) diff --git a/apps/cli-docs/src/content/docs/exit-codes.md b/apps/cli-docs/src/content/docs/exit-codes.md index c3dc3f6f9..24aba7a58 100644 --- a/apps/cli-docs/src/content/docs/exit-codes.md +++ b/apps/cli-docs/src/content/docs/exit-codes.md @@ -39,6 +39,7 @@ react to failure categories without parsing stderr. | 40 | Seer Not Enabled | Seer is not enabled for the organization | | 41 | Seer No Budget | Seer requires a paid plan | | 42 | AI Disabled | AI features disabled by organization admin | +| 43 | Seer Out Of Credits | Organization is out of Seer credits | | 50 | Upgrade Error | CLI upgrade operation failed | | 51 | Device Flow Error | OAuth device authorization flow failed | | 60 | Output Error | Command produced output but the operation failed | diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index cdb1952b6..8dea54d29 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -907,7 +907,7 @@ CliError (base, exitCode=1) ├── ResolutionError (exitCode=23 — value provided but not found) ├── ValidationError (exitCode=21 — input validation) ├── DeviceFlowError (exitCode=51 — OAuth flow) -├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled') +├── SeerError (exitCode=40–43 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled' | 'out_of_credits') ├── TimeoutError (exitCode=31 — operation timed out) └── UpgradeError (exitCode=50 — upgrade failures) ``` diff --git a/packages/cli/src/commands/issue/explain.ts b/packages/cli/src/commands/issue/explain.ts index 5bfcf66d2..19262ebc6 100644 --- a/packages/cli/src/commands/issue/explain.ts +++ b/packages/cli/src/commands/issue/explain.ts @@ -74,7 +74,12 @@ async function analyzeIssue( return { issue: issueArg.text, org, issueId, rootCauses }; } catch (error) { if (error instanceof ApiError) { - throw handleSeerApiError(error.status, error.detail, resolvedOrg); + throw handleSeerApiError( + error.status, + error.detail, + resolvedOrg, + error.code, + ); } throw error; } diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 9c735f7f1..670de06e1 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -11,7 +11,7 @@ import { triggerSolutionPlanning } from "../../lib/api-client.js"; import { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { ApiError, CliError } from "../../lib/errors.js"; +import { ApiError, CliError, SeerError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatSolution, @@ -323,6 +323,9 @@ export const planCommand = buildCommand({ }); if (finalState.status === "ERROR") { + if (finalState.failure_reason === "out_of_credits") { + throw new SeerError("out_of_credits", org); + } throw new Error( "Plan creation failed. Check the Sentry web UI for details.", ); @@ -342,7 +345,12 @@ export const planCommand = buildCommand({ return yield new CommandOutput(buildPlanData(finalState)); } catch (error) { if (error instanceof ApiError) { - throw handleSeerApiError(error.status, error.detail, resolvedOrg); + throw handleSeerApiError( + error.status, + error.detail, + resolvedOrg, + error.code, + ); } throw error; } diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 6bfd88821..04a502788 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -36,6 +36,7 @@ import { type AuthGuardFailure, ContextError, ResolutionError, + SeerError, withAuthGuard, } from "../../lib/errors.js"; import { getProgressMessage } from "../../lib/formatters/seer.js"; @@ -1086,6 +1087,7 @@ type EnsureRootCauseOptions = { * * @param options - Configuration options * @returns The completed autofix state with root causes + * @throws {SeerError} When the run stopped because the org ran out of Seer credits */ export async function ensureRootCauseAnalysis( options: EnsureRootCauseOptions, @@ -1126,6 +1128,10 @@ export async function ensureRootCauseAnalysis( }); } + if (state.status === "ERROR" && state.failure_reason === "out_of_credits") { + throw new SeerError("out_of_credits", org); + } + return state; } diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index b2c81820b..c348da24e 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -83,6 +83,8 @@ export const EXIT = { SEER_NO_BUDGET: 41, /** AI features disabled by org admin */ SEER_AI_DISABLED: 42, + /** Organization is out of Seer credits */ + SEER_OUT_OF_CREDITS: 43, // 50–59: Operations /** CLI upgrade failed */ @@ -700,7 +702,11 @@ export class UpgradeTransportError extends UpgradeError { // Seer Errors -export type SeerErrorReason = "not_enabled" | "no_budget" | "ai_disabled"; +export type SeerErrorReason = + | "not_enabled" + | "no_budget" + | "ai_disabled" + | "out_of_credits"; /** * Seer-specific errors with actionable suggestions. @@ -717,11 +723,13 @@ export class SeerError extends CliError { not_enabled: "Seer is not enabled for this organization.", no_budget: "Seer requires a paid plan.", ai_disabled: "AI features are disabled for this organization.", + out_of_credits: "Your organization is out of Seer credits.", }; const exitCodes: Record = { not_enabled: EXIT.SEER_NOT_ENABLED, no_budget: EXIT.SEER_NO_BUDGET, ai_disabled: EXIT.SEER_AI_DISABLED, + out_of_credits: EXIT.SEER_OUT_OF_CREDITS, }; super(messages[reason], exitCodes[reason]); this.name = "SeerError"; @@ -746,6 +754,8 @@ export class SeerError extends CliError { no_budget: `To use Seer features, upgrade your plan:\n ${buildBillingUrl(this.orgSlug, "seer")}${trialHint}`, // ai_disabled is an admin decision — don't suggest trial ai_disabled: `To enable AI features:\n ${buildOrgSettingsUrl(this.orgSlug, "hideAiFeatures")}`, + // out_of_credits orgs already have Seer, so a trial is not the fix + out_of_credits: `Someone with billing access can turn on auto-reload in Sentry:\n ${buildBillingUrl(this.orgSlug, "seer")}`, }; return `${this.message}\n\n${suggestions[this.reason]}`; } @@ -757,6 +767,9 @@ export class SeerError extends CliError { // ai_disabled is an admin decision — don't suggest trial ai_disabled: "To enable AI features, check the 'Hide AI Features' setting in your organization settings.", + // out_of_credits orgs already have Seer, so a trial is not the fix + out_of_credits: + "Someone with billing access can turn on auto-reload in your organization's billing settings.", }; return `${this.message}\n\n${fallbackSuggestions[this.reason]}`; } diff --git a/packages/cli/src/lib/formatters/seer.ts b/packages/cli/src/lib/formatters/seer.ts index 5b44fbee4..43880a25f 100644 --- a/packages/cli/src/lib/formatters/seer.ts +++ b/packages/cli/src/lib/formatters/seer.ts @@ -260,21 +260,33 @@ export function jsonTransformIssueExplain( // Error Messages +/** + * `code` Sentry sends with a 402 when the organization is out of Seer credits. + * Any other 402 means no budget, including one with no code from a Sentry + * version that predates the codes. + */ +const OUT_OF_CREDITS_CODE = "seer_out_of_credits"; + /** * Create a SeerError from an API error status code and detail. * * @param status - HTTP status code * @param detail - Error detail from API * @param orgSlug - Organization slug for constructing settings URLs + * @param code - Machine-readable `code` from the error body * @returns SeerError if the status code indicates a Seer-specific error, null otherwise */ export function createSeerError( status: number, detail?: string, orgSlug?: string, + code?: string, ): SeerError | null { if (status === 402) { - return new SeerError("no_budget", orgSlug); + return new SeerError( + code === OUT_OF_CREDITS_CODE ? "out_of_credits" : "no_budget", + orgSlug, + ); } if (status === 403) { if (detail?.includes("not enabled")) { @@ -301,14 +313,16 @@ export function createSeerError( * @param status - HTTP status code * @param detail - Error detail from API * @param orgSlug - Organization slug for constructing settings URLs + * @param code - Machine-readable `code` from the error body * @returns SeerError for Seer-specific errors, or ApiError for other API errors */ export function handleSeerApiError( status: number, detail?: string, orgSlug?: string, + code?: string, ): Error { - const seerError = createSeerError(status, detail, orgSlug); + const seerError = createSeerError(status, detail, orgSlug, code); if (seerError) { return seerError; } diff --git a/packages/cli/src/types/seer.ts b/packages/cli/src/types/seer.ts index a706dd945..ea08d72f6 100644 --- a/packages/cli/src/types/seer.ts +++ b/packages/cli/src/types/seer.ts @@ -189,6 +189,8 @@ export const AutofixStateSchema = looseObject({ /** Null for legacy runs predating this field. */ sentry_run_id: optional(nullable(string())), status: string(), + /** Why a failed run stopped, e.g. `out_of_credits`. Null when it didn't fail. */ + failure_reason: optional(nullable(string())), updated_at: optional(string()), request: optional( object({ diff --git a/packages/cli/test/commands/issue/plan.func.test.ts b/packages/cli/test/commands/issue/plan.func.test.ts index 16cfa4dac..cf91ccae4 100644 --- a/packages/cli/test/commands/issue/plan.func.test.ts +++ b/packages/cli/test/commands/issue/plan.func.test.ts @@ -191,6 +191,20 @@ describe("issue plan after solution planning", () => { ); }); + test("throws out_of_credits when planning stopped for credits", async () => { + pollSpy.mockResolvedValue({ + status: "ERROR", + failure_reason: "out_of_credits", + run_id: 42, + steps: [], + }); + + await expect(runPlan(createMockContext())).rejects.toMatchObject({ + reason: "out_of_credits", + orgSlug: "test-org", + }); + }); + test("throws when planning ends in CANCELLED", async () => { pollSpy.mockResolvedValue({ status: "CANCELLED", run_id: 42, steps: [] }); diff --git a/packages/cli/test/commands/issue/utils.test.ts b/packages/cli/test/commands/issue/utils.test.ts index 8dd3b7ed8..dfeea918d 100644 --- a/packages/cli/test/commands/issue/utils.test.ts +++ b/packages/cli/test/commands/issue/utils.test.ts @@ -1584,6 +1584,36 @@ describe("ensureRootCauseAnalysis", () => { expect(triggerCalled).toBe(true); // Should have retried }); + test("throws out_of_credits when the run stopped for credits", async () => { + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const body = + req.method === "POST" + ? { run_id: 12_346 } + : { + autofix: { + run_id: 12_346, + status: "ERROR", + failure_reason: "out_of_credits", + steps: [], + }, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await expect( + ensureRootCauseAnalysis({ + org: "test-org", + issueId: "123456789", + json: true, + }), + ).rejects.toMatchObject({ reason: "out_of_credits", orgSlug: "test-org" }); + }); + test("polls until complete when state is PROCESSING", async () => { let fetchCount = 0; diff --git a/packages/cli/test/lib/errors.test.ts b/packages/cli/test/lib/errors.test.ts index 18b468d5f..bbba9f3f2 100644 --- a/packages/cli/test/lib/errors.test.ts +++ b/packages/cli/test/lib/errors.test.ts @@ -397,6 +397,14 @@ describe("SeerError", () => { expect(formatted).toContain("my-org"); }); + test("format() includes billing URL for out_of_credits with orgSlug", () => { + const formatted = new SeerError("out_of_credits", "my-org").format(); + expect(formatted).toContain("Your organization is out of Seer credits."); + expect(formatted).toContain( + "https://my-org.sentry.io/settings/billing/overview/?product=seer", + ); + }); + test("format() shows fallback message without orgSlug", () => { const err = new SeerError("not_enabled"); const formatted = err.format(); @@ -778,6 +786,9 @@ describe("exit codes", () => { expect(new SeerError("not_enabled").exitCode).toBe(EXIT.SEER_NOT_ENABLED); expect(new SeerError("no_budget").exitCode).toBe(EXIT.SEER_NO_BUDGET); expect(new SeerError("ai_disabled").exitCode).toBe(EXIT.SEER_AI_DISABLED); + expect(new SeerError("out_of_credits").exitCode).toBe( + EXIT.SEER_OUT_OF_CREDITS, + ); }); test("TimeoutError has exit code TIMEOUT", () => { diff --git a/packages/cli/test/lib/formatters/seer.test.ts b/packages/cli/test/lib/formatters/seer.test.ts index 54110414e..410f5cc54 100644 --- a/packages/cli/test/lib/formatters/seer.test.ts +++ b/packages/cli/test/lib/formatters/seer.test.ts @@ -385,6 +385,16 @@ describe("createSeerError", () => { expect(error?.orgSlug).toBe("my-org"); }); + test("returns out_of_credits for a 402 with the out-of-credits code", () => { + const error = createSeerError( + 402, + undefined, + "my-org", + "seer_out_of_credits", + ); + expect(error?.reason).toBe("out_of_credits"); + }); + test("returns SeerError for 403 with 'not enabled' detail", () => { const error = createSeerError(403, "Seer is not enabled", "my-org"); expect(error).toBeInstanceOf(SeerError); From 4e369ceb5b134c9f7e1aa652816dee4dc28ac04a Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Wed, 11 Nov 2026 11:03:03 -0600 Subject: [PATCH 2/3] test(cli): Cover the Seer out-of-credits 402 and trial exclusion Nothing failed if the commands stopped passing the 402 code to the Seer error mapping. issue explain and issue plan now have a test for a 402 with seer_out_of_credits, and the API client test checks the code reaches the ApiError. The trial tests mock isatty, so they can assert that no_budget and not_enabled offer a trial in a terminal and out_of_credits does not. Refs CW-3075 --- .../test/commands/issue/explain.func.test.ts | 20 ++++++++- .../cli/test/commands/issue/plan.func.test.ts | 20 ++++++++- packages/cli/test/lib/api-client.seer.test.ts | 19 +++++++++ packages/cli/test/lib/formatters/seer.test.ts | 10 +++++ packages/cli/test/lib/seer-trial.test.ts | 41 +++++++++++++++---- 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/packages/cli/test/commands/issue/explain.func.test.ts b/packages/cli/test/commands/issue/explain.func.test.ts index 64ea364f5..5c3afb13c 100644 --- a/packages/cli/test/commands/issue/explain.func.test.ts +++ b/packages/cli/test/commands/issue/explain.func.test.ts @@ -20,7 +20,7 @@ vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => { import { explainCommand } from "../../../src/commands/issue/explain.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as issueUtils from "../../../src/commands/issue/utils.js"; -import { ContextError } from "../../../src/lib/errors.js"; +import { ApiError, ContextError } from "../../../src/lib/errors.js"; import type { IssueArgument } from "../../../src/lib/issue-arguments.js"; import type { AutofixState, RootCause } from "../../../src/types/seer.js"; @@ -172,6 +172,24 @@ describe("issue explain multiple IDs", () => { ).rejects.toBe(error); }); + test("throws out_of_credits when Sentry refuses the analysis with a 402", async () => { + resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" }); + analyzeSpy.mockRejectedValue( + new ApiError( + "Failed to start root cause analysis: 402 Payment Required", + 402, + "Your organization does not have enough credits to start an Autofix run.", + undefined, + { code: "seer_out_of_credits" }, + ), + ); + + const func = await explainCommand.loader(); + await expect( + func.call(createMockContext().context, EXPLAIN_FLAGS, "IOS-1"), + ).rejects.toMatchObject({ reason: "out_of_credits", orgSlug: "test-org" }); + }); + test("fails to parse when no issue ID is provided", async () => { const func = await explainCommand.loader(); await expect( diff --git a/packages/cli/test/commands/issue/plan.func.test.ts b/packages/cli/test/commands/issue/plan.func.test.ts index cf91ccae4..a541edfa0 100644 --- a/packages/cli/test/commands/issue/plan.func.test.ts +++ b/packages/cli/test/commands/issue/plan.func.test.ts @@ -23,7 +23,7 @@ import { planCommand } from "../../../src/commands/issue/plan.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- 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 { ApiError, CliError } from "../../../src/lib/errors.js"; import { logger } from "../../../src/lib/logger.js"; import { prompt } from "../../../src/lib/prompt.js"; import type { AutofixState, RootCause } from "../../../src/types/seer.js"; @@ -205,6 +205,24 @@ describe("issue plan after solution planning", () => { }); }); + test("throws out_of_credits when Sentry refuses planning with a 402", async () => { + vi.mocked(triggerSolutionPlanning).mockRejectedValueOnce( + new ApiError( + "Failed to start solution planning: 402 Payment Required", + 402, + "Your organization does not have enough credits to continue your Autofix run.", + undefined, + { code: "seer_out_of_credits" }, + ), + ); + + await expect(runPlan(createMockContext())).rejects.toMatchObject({ + reason: "out_of_credits", + orgSlug: "test-org", + }); + expect(pollSpy).not.toHaveBeenCalled(); + }); + test("throws when planning ends in CANCELLED", async () => { pollSpy.mockResolvedValue({ status: "CANCELLED", run_id: 42, steps: [] }); diff --git a/packages/cli/test/lib/api-client.seer.test.ts b/packages/cli/test/lib/api-client.seer.test.ts index 99fa50aaf..0a72ac85e 100644 --- a/packages/cli/test/lib/api-client.seer.test.ts +++ b/packages/cli/test/lib/api-client.seer.test.ts @@ -89,6 +89,25 @@ describe("triggerRootCauseAnalysis", () => { ).rejects.toThrow(); }); + test("keeps the code from a 402 body on the ApiError", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + detail: + "Your organization does not have enough credits to start an Autofix run.", + code: "seer_out_of_credits", + }), + { status: 402, headers: { "Content-Type": "application/json" } }, + ); + + const error = await triggerRootCauseAnalysis("test-org", "123456789").catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 402, code: "seer_out_of_credits" }); + }); + test("throws ApiError on 403 response", async () => { globalThis.fetch = async () => new Response(JSON.stringify({ detail: "AI Autofix is not enabled" }), { diff --git a/packages/cli/test/lib/formatters/seer.test.ts b/packages/cli/test/lib/formatters/seer.test.ts index 410f5cc54..99cc4803b 100644 --- a/packages/cli/test/lib/formatters/seer.test.ts +++ b/packages/cli/test/lib/formatters/seer.test.ts @@ -395,6 +395,16 @@ describe("createSeerError", () => { expect(error?.reason).toBe("out_of_credits"); }); + test("returns no_budget for a 402 with the out-of-quota code", () => { + const error = createSeerError( + 402, + undefined, + "my-org", + "seer_out_of_quota", + ); + expect(error?.reason).toBe("no_budget"); + }); + test("returns SeerError for 403 with 'not enabled' detail", () => { const error = createSeerError(403, "Seer is not enabled", "my-org"); expect(error).toBeInstanceOf(SeerError); diff --git a/packages/cli/test/lib/seer-trial.test.ts b/packages/cli/test/lib/seer-trial.test.ts index 0f5283243..d95f7a672 100644 --- a/packages/cli/test/lib/seer-trial.test.ts +++ b/packages/cli/test/lib/seer-trial.test.ts @@ -1,15 +1,19 @@ /** * Seer Trial Prompt Tests * - * Tests for the interactive trial prompt flow. - * Note: isTrialEligible tests that depend on isatty(0) mocking live in - * test/isolated/ to avoid mock.module pollution. Tests here focus on - * promptAndStartTrial which doesn't call isatty directly. + * Tests for the interactive trial prompt flow. `isatty` is mocked so the + * isTrialEligible tests can simulate an interactive terminal. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("../../src/lib/prompt.js", () => ({ prompt: vi.fn() })); +vi.mock("node:tty", async (importOriginal) => ({ + ...(await importOriginal()), + isatty: vi.fn(() => false), +})); + +import { isatty } from "node:tty"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as apiClient from "../../src/lib/api-client.js"; @@ -23,15 +27,38 @@ import { } from "../../src/lib/seer-trial.js"; describe("isTrialEligible", () => { - // Note: These tests run in a non-interactive terminal (bun test), - // so isatty(0) returns false. We can only test the false cases here. - // The positive case (isatty=true) would need mock.module in an isolated test. + // Simulate an interactive terminal, so a false result comes from the error + // itself and not from the terminal check. + beforeEach(() => { + vi.mocked(isatty).mockReturnValue(true); + }); + + afterEach(() => { + vi.mocked(isatty).mockReturnValue(false); + }); + + test.each(["no_budget", "not_enabled"] as const)( + "returns true for %s reason in an interactive terminal", + (reason) => { + expect(isTrialEligible(new SeerError(reason, "test-org"))).toBe(true); + }, + ); + + test("returns false outside an interactive terminal", () => { + vi.mocked(isatty).mockReturnValue(false); + expect(isTrialEligible(new SeerError("no_budget", "test-org"))).toBe(false); + }); test("returns false for ai_disabled reason", () => { const err = new SeerError("ai_disabled", "test-org"); expect(isTrialEligible(err)).toBe(false); }); + test("returns false for out_of_credits reason", () => { + const err = new SeerError("out_of_credits", "test-org"); + expect(isTrialEligible(err)).toBe(false); + }); + test("returns false when orgSlug is undefined", () => { const err = new SeerError("no_budget"); expect(isTrialEligible(err)).toBe(false); From 154bac7258e4a10f6deb21f59dfb2054f4bb2031 Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Wed, 11 Nov 2026 11:03:11 -0600 Subject: [PATCH 3/3] fix(cli): Reword the Seer out-of-credits advice The message told users to turn on auto-reload. An org with auto-reload on can still run out once it hits its daily usage limit, and Sentry does not say which case applies. The advice now covers both. Refs CW-3075 --- packages/cli/src/lib/errors.ts | 8 +++++--- packages/cli/src/types/seer.ts | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index c348da24e..068a797ec 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -754,8 +754,10 @@ export class SeerError extends CliError { no_budget: `To use Seer features, upgrade your plan:\n ${buildBillingUrl(this.orgSlug, "seer")}${trialHint}`, // ai_disabled is an admin decision — don't suggest trial ai_disabled: `To enable AI features:\n ${buildOrgSettingsUrl(this.orgSlug, "hideAiFeatures")}`, - // out_of_credits orgs already have Seer, so a trial is not the fix - out_of_credits: `Someone with billing access can turn on auto-reload in Sentry:\n ${buildBillingUrl(this.orgSlug, "seer")}`, + // out_of_credits orgs already have Seer, so a trial is not the fix. + // Sentry doesn't say whether auto-reload is off or has run through its + // top-ups, so the advice covers both. + out_of_credits: `Someone with billing access can adjust Seer spend and auto-reload in Sentry:\n ${buildBillingUrl(this.orgSlug, "seer")}`, }; return `${this.message}\n\n${suggestions[this.reason]}`; } @@ -769,7 +771,7 @@ export class SeerError extends CliError { "To enable AI features, check the 'Hide AI Features' setting in your organization settings.", // out_of_credits orgs already have Seer, so a trial is not the fix out_of_credits: - "Someone with billing access can turn on auto-reload in your organization's billing settings.", + "Someone with billing access can adjust Seer spend and auto-reload in your organization's billing settings.", }; return `${this.message}\n\n${fallbackSuggestions[this.reason]}`; } diff --git a/packages/cli/src/types/seer.ts b/packages/cli/src/types/seer.ts index ea08d72f6..db7bc844e 100644 --- a/packages/cli/src/types/seer.ts +++ b/packages/cli/src/types/seer.ts @@ -189,7 +189,10 @@ export const AutofixStateSchema = looseObject({ /** Null for legacy runs predating this field. */ sentry_run_id: optional(nullable(string())), status: string(), - /** Why a failed run stopped, e.g. `out_of_credits`. Null when it didn't fail. */ + /** + * Why a failed run stopped, e.g. `out_of_credits`. Null when the run didn't + * fail, and also when it failed for a reason Seer doesn't classify. + */ failure_reason: optional(nullable(string())), updated_at: optional(string()), request: optional(