Skip to content

Commit ebea42e

Browse files
committed
feat(seer): prompt to start free trial on budget/enablement errors
When `issue explain` or `issue plan` fails with a 402 (no budget) or 403 (not enabled) error in an interactive terminal, the CLI now checks if the org has an available Seer product trial. If one exists, it prompts the user to start a free trial and retries the command on success. The flow mirrors the existing `executeWithAutoAuth` pattern: a new `executeWithSeerTrialPrompt` middleware wraps `runCommand` and catches `SeerError`. Errors that aren't trial-eligible (`ai_disabled`, missing org slug, non-TTY) pass through unchanged. Key behaviors: - Checks `GET /customers/{org}/` for unstarted seerUsers trial (falls back to seerAutofix for legacy orgs) - Starts trial via `PUT /customers/{org}/product-trial/` - API failures during trial check degrade gracefully (show original error) - consola prompt cancel handled with strict equality check
1 parent 4956615 commit ebea42e

File tree

9 files changed

+851
-7
lines changed

9 files changed

+851
-7
lines changed

AGENTS.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -645,9 +645,6 @@ mock.module("./some-module", () => ({
645645
646646
### Decision
647647
648-
<!-- lore:019cc2ef-9be5-722d-bc9f-b07a8197eeed -->
649-
* **All view subcommands should use \<target> \<id> positional pattern**: All \`\* view\` subcommands should follow a consistent \`\<target> \<id>\` positional argument pattern where target is the optional \`org/project\` specifier. During migration, use opportunistic argument swapping with a stderr warning when args are in wrong order. This is an instance of the broader CLI UX auto-correction pattern: safe when input is already invalid, correction is unambiguous, warning goes to stderr. Normalize at command level, keep parsers pure. Model after \`gh\` CLI conventions.
650-
651648
<!-- lore:019c99d5-69f2-74eb-8c86-411f8512801d -->
652649
* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions.
653650
@@ -689,7 +686,7 @@ mock.module("./some-module", () => ({
689686
* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves.
690687
691688
<!-- lore:5ac4e219-ea1f-41cb-8e97-7e946f5848c0 -->
692-
* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \<PR> --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`.
689+
* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in getsentry/cli, CI includes Seer Code Review and Cursor Bugbot as advisory checks (~2-3 min). They may not trigger on draft PRs — use \`gh pr ready\` first. Workflow: push → \`gh pr checks \<PR> --watch\` → check for bot review comments → fix → repeat. Use GraphQL \`reviewThreads\` query with \`isResolved\` filter to find unresolved comments. Reply to bot comments after fixing.
693690
694691
<!-- lore:019cb162-d3ad-7b05-ab4f-f87892d517a6 -->
695692
* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error.

src/bin.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ import { isatty } from "node:tty";
22
import { run } from "@stricli/core";
33
import { app } from "./app.js";
44
import { buildContext } from "./context.js";
5-
import { AuthError, formatError, getExitCode } from "./lib/errors.js";
5+
import {
6+
AuthError,
7+
formatError,
8+
getExitCode,
9+
SeerError,
10+
} from "./lib/errors.js";
611
import { error } from "./lib/formatters/colors.js";
712
import { runInteractiveLogin } from "./lib/interactive-login.js";
813
import { getEnvLogLevel, setLogLevel } from "./lib/logger.js";
14+
import { isTrialEligible, promptAndStartTrial } from "./lib/seer-trial.js";
915
import { withTelemetry } from "./lib/telemetry.js";
1016
import { startCleanupOldBinary } from "./lib/upgrade.js";
1117
import {
@@ -36,6 +42,45 @@ async function runCommand(args: string[]): Promise<void> {
3642
);
3743
}
3844

45+
/**
46+
* Execute command with automatic Seer trial prompt.
47+
*
48+
* If the command fails with a trial-eligible SeerError in an interactive TTY,
49+
* checks for available trial, prompts user, starts trial, and retries.
50+
*
51+
* Shows a brief context message (not the full error format with URLs) before
52+
* the trial prompt. If the trial isn't available or the user declines, the
53+
* full error is re-thrown so the outer handler in main() displays it normally.
54+
*
55+
* @throws Re-throws the original error when trial is unavailable or declined
56+
*/
57+
async function executeWithSeerTrialPrompt(args: string[]): Promise<void> {
58+
try {
59+
await runCommand(args);
60+
} catch (err) {
61+
if (err instanceof SeerError && isTrialEligible(err)) {
62+
// isTrialEligible ensures orgSlug is defined
63+
const started = await promptAndStartTrial(
64+
// biome-ignore lint/style/noNonNullAssertion: isTrialEligible guarantees orgSlug is defined
65+
err.orgSlug!,
66+
err.reason,
67+
process.stderr
68+
);
69+
70+
if (started) {
71+
process.stderr.write("\nRetrying command...\n\n");
72+
await runCommand(args);
73+
return;
74+
}
75+
76+
// Trial not started (unavailable, declined, or failed) — re-throw
77+
// so the outer error handler in main() displays the full error
78+
// with the upgrade/settings URL
79+
}
80+
throw err;
81+
}
82+
}
83+
3984
/**
4085
* Execute command with automatic authentication.
4186
*
@@ -46,7 +91,7 @@ async function runCommand(args: string[]): Promise<void> {
4691
*/
4792
async function executeWithAutoAuth(args: string[]): Promise<void> {
4893
try {
49-
await runCommand(args);
94+
await executeWithSeerTrialPrompt(args);
5095
} catch (err) {
5196
// Auto-login for auth errors in interactive TTY environments
5297
// Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun)
@@ -71,7 +116,7 @@ async function executeWithAutoAuth(args: string[]): Promise<void> {
71116

72117
if (loginSuccess) {
73118
process.stderr.write("\nRetrying command...\n\n");
74-
await runCommand(args);
119+
await executeWithSeerTrialPrompt(args);
75120
return;
76121
}
77122

src/lib/api-client.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ import * as Sentry from "@sentry/bun";
3636
import type { z } from "zod";
3737

3838
import {
39+
type CustomerTrialInfo,
40+
CustomerTrialInfoSchema,
3941
DetailedLogsResponseSchema,
4042
type DetailedSentryLog,
4143
LogsResponseSchema,
44+
type ProductTrial,
4245
type ProjectKey,
4346
type Region,
4447
type SentryEvent,
@@ -1689,6 +1692,61 @@ export async function triggerSolutionPlanning(
16891692
return data;
16901693
}
16911694

1695+
// Seer Trial functions
1696+
1697+
/**
1698+
* Check if a Seer product trial is available for the organization.
1699+
*
1700+
* Fetches customer data from the internal `/customers/{org}/` endpoint and
1701+
* looks for an unstarted `seerUsers` trial. Returns the trial object if
1702+
* available, or null if no trial exists or it's already started.
1703+
*
1704+
* @param orgSlug - Organization slug
1705+
* @returns The unstarted trial if available, null otherwise
1706+
*/
1707+
export async function getSeerTrialStatus(
1708+
orgSlug: string
1709+
): Promise<ProductTrial | null> {
1710+
const regionUrl = await resolveOrgRegion(orgSlug);
1711+
const { data } = await apiRequestToRegion<CustomerTrialInfo>(
1712+
regionUrl,
1713+
`/customers/${orgSlug}/`,
1714+
{ schema: CustomerTrialInfoSchema }
1715+
);
1716+
const trials = data.productTrials ?? [];
1717+
// Prefer seat-based seerUsers, fall back to legacy seerAutofix
1718+
return (
1719+
trials.find((t) => t.category === "seerUsers" && !t.isStarted) ??
1720+
trials.find((t) => t.category === "seerAutofix" && !t.isStarted) ??
1721+
null
1722+
);
1723+
}
1724+
1725+
/**
1726+
* Start a Seer product trial for the organization.
1727+
*
1728+
* Sends a PUT to the internal `/customers/{org}/product-trial/` endpoint
1729+
* to activate a 14-day Seer trial. Any org member with `org:read` or higher
1730+
* permission can start a trial.
1731+
*
1732+
* @param orgSlug - Organization slug
1733+
* @param category - Trial category from getSeerTrialStatus (e.g., "seerUsers", "seerAutofix")
1734+
* @throws {ApiError} On API errors (e.g., trial already active, permissions)
1735+
*/
1736+
export async function startSeerTrial(
1737+
orgSlug: string,
1738+
category: string
1739+
): Promise<void> {
1740+
const regionUrl = await resolveOrgRegion(orgSlug);
1741+
await apiRequestToRegion(regionUrl, `/customers/${orgSlug}/product-trial/`, {
1742+
method: "PUT",
1743+
body: {
1744+
referrer: "sentry-cli",
1745+
productTrial: { category, reasonCode: 0 },
1746+
},
1747+
});
1748+
}
1749+
16921750
// User functions
16931751

16941752
/**

src/lib/seer-trial.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Seer Trial Prompt
3+
*
4+
* Interactive flow to check for and start a Seer product trial
5+
* when a Seer command fails due to budget/enablement errors.
6+
*
7+
* Called from bin.ts when a SeerError is caught. Checks trial availability
8+
* via the customer API, prompts the user for confirmation, and starts the
9+
* trial if accepted. All failures degrade gracefully — the original error
10+
* is re-thrown by the caller if this function returns false.
11+
*/
12+
13+
import { isatty } from "node:tty";
14+
15+
import { getSeerTrialStatus, startSeerTrial } from "./api-client.js";
16+
import type { SeerError, SeerErrorReason } from "./errors.js";
17+
import { success } from "./formatters/colors.js";
18+
import { logger } from "./logger.js";
19+
20+
/** Seer error reasons eligible for trial prompt */
21+
const TRIAL_ELIGIBLE_REASONS: ReadonlySet<SeerErrorReason> = new Set([
22+
"no_budget",
23+
"not_enabled",
24+
]);
25+
26+
/** User-facing context messages shown before the trial prompt */
27+
const REASON_CONTEXT: Record<string, string> = {
28+
no_budget: "Your organization has run out of Seer quota.",
29+
not_enabled: "Seer is not enabled for your organization.",
30+
};
31+
32+
/**
33+
* Check whether a SeerError is eligible for a trial prompt.
34+
*
35+
* Only `no_budget` and `not_enabled` are eligible — `ai_disabled` is
36+
* an explicit admin decision that a trial wouldn't override.
37+
* Requires orgSlug (needed for API calls) and interactive terminal.
38+
*
39+
* @param error - The SeerError to check
40+
* @returns true if the error is eligible for a trial prompt
41+
*/
42+
export function isTrialEligible(error: SeerError): boolean {
43+
return (
44+
TRIAL_ELIGIBLE_REASONS.has(error.reason) &&
45+
error.orgSlug !== undefined &&
46+
isatty(0)
47+
);
48+
}
49+
50+
/**
51+
* Attempt to offer and start a Seer trial.
52+
*
53+
* Flow:
54+
* 1. Check trial availability via API (graceful failure → return false)
55+
* 2. Show context message + prompt user for confirmation
56+
* 3. Start the trial via API
57+
*
58+
* @param orgSlug - Organization slug
59+
* @param reason - The SeerError reason (for context message)
60+
* @param stderr - Stderr stream for messages
61+
* @returns true if trial was started successfully, false otherwise
62+
*/
63+
export async function promptAndStartTrial(
64+
orgSlug: string,
65+
reason: SeerErrorReason,
66+
stderr: NodeJS.WriteStream
67+
): Promise<boolean> {
68+
// 1. Check trial availability (graceful failure → return false)
69+
let trial: Awaited<ReturnType<typeof getSeerTrialStatus>>;
70+
try {
71+
trial = await getSeerTrialStatus(orgSlug);
72+
} catch {
73+
// Can't check trial status — degrade gracefully
74+
return false;
75+
}
76+
77+
if (!trial) {
78+
// No trial available — fall through to normal error
79+
return false;
80+
}
81+
82+
// 2. Show context and prompt
83+
const context = REASON_CONTEXT[reason] ?? "";
84+
if (context) {
85+
stderr.write(`${context}\n`);
86+
}
87+
88+
const daysText = trial.lengthDays ? `${trial.lengthDays}-day ` : "";
89+
const log = logger.withTag("seer");
90+
const confirmed = await log.prompt(
91+
`A free ${daysText}Seer trial is available. Start trial?`,
92+
{ type: "confirm", initial: true }
93+
);
94+
95+
// Symbol(clack:cancel) is truthy — strict equality check
96+
if (confirmed !== true) {
97+
return false;
98+
}
99+
100+
// 3. Start trial using the category from the available trial
101+
try {
102+
stderr.write("\nStarting Seer trial...\n");
103+
await startSeerTrial(orgSlug, trial.category);
104+
stderr.write(`${success("✓")} Seer trial activated!\n`);
105+
return true;
106+
} catch {
107+
stderr.write(
108+
"Failed to start trial. Please try again or visit your Sentry settings.\n"
109+
);
110+
return false;
111+
}
112+
}

src/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type {
4949
Breadcrumb,
5050
BreadcrumbsEntry,
5151
BrowserContext,
52+
CustomerTrialInfo,
5253
DetailedLogsResponse,
5354
DetailedSentryLog,
5455
DeviceContext,
@@ -59,6 +60,7 @@ export type {
5960
LogsResponse,
6061
Mechanism,
6162
OsContext,
63+
ProductTrial,
6264
ProjectKey,
6365
Region,
6466
RepositoryProvider,
@@ -83,11 +85,13 @@ export type {
8385
} from "./sentry.js";
8486

8587
export {
88+
CustomerTrialInfoSchema,
8689
DetailedLogsResponseSchema,
8790
DetailedSentryLogSchema,
8891
ISSUE_LEVELS,
8992
ISSUE_STATUSES,
9093
LogsResponseSchema,
94+
ProductTrialSchema,
9195
RegionSchema,
9296
RepositoryProviderSchema,
9397
SentryLogSchema,

src/types/sentry.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,31 @@ export const SentryTeamSchema = z
714714
.passthrough();
715715

716716
export type SentryTeam = z.infer<typeof SentryTeamSchema>;
717+
718+
// Product Trials
719+
720+
/** A product trial from the customer endpoint */
721+
export const ProductTrialSchema = z.object({
722+
/** Trial category (e.g., "seerUsers", "seerAutofix") */
723+
category: z.string(),
724+
/** ISO date when the trial started, null if not started */
725+
startDate: z.string().nullable(),
726+
/** ISO date when the trial ends, null if not started */
727+
endDate: z.string().nullable(),
728+
/** Reason code for the trial */
729+
reasonCode: z.number(),
730+
/** Whether the trial has been activated */
731+
isStarted: z.boolean(),
732+
/** Duration of the trial in days, null if unknown */
733+
lengthDays: z.number().nullable(),
734+
});
735+
736+
export type ProductTrial = z.infer<typeof ProductTrialSchema>;
737+
738+
/** Subset of customer data needed for trial availability checks */
739+
export const CustomerTrialInfoSchema = z.object({
740+
/** Available and active product trials for the organization */
741+
productTrials: z.array(ProductTrialSchema).optional(),
742+
});
743+
744+
export type CustomerTrialInfo = z.infer<typeof CustomerTrialInfoSchema>;

0 commit comments

Comments
 (0)