Skip to content
5 changes: 4 additions & 1 deletion static/app/components/events/autofix/autofixCredits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
type ExplorerAutofixState,
} from './useExplorerAutofix';

type AutofixAction = AutofixNextStep['action'];
/**
* A billable autofix action: a step, opening a PR, or iterating on one.
*/
export type AutofixAction = AutofixNextStep['action'];

const AUTOFIX_CREDIT_OPERATIONS: Record<AutofixAction, SeerCreditOperation> = {
root_cause: 'autofix_root_cause',
Expand Down
140 changes: 140 additions & 0 deletions static/app/components/events/autofix/useExplorerAutofix.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLib

import {toast} from '@sentry/scraps/toast';

import {useSeerCreditCheck} from 'sentry/utils/seer/useSeerCreditCheck';
import type {Artifact, Block, ExplorerFilePatch} from 'sentry/views/seerExplorer/types';

import {DiffFileType, DiffLineType} from './types';
Expand All @@ -18,6 +19,7 @@ import {
isCodeChangesArtifact,
isCodingAgentsArtifact,
isLastStepPrIteration,
isOutOfCreditsSection,
isPrIterationBlock,
isPullRequestsArtifact,
isRootCauseArtifact,
Expand Down Expand Up @@ -640,6 +642,34 @@ describe('getOrderedAutofixSections', () => {
expect(prSection.artifacts).toEqual([[prState]]);
});

it('marks only the section the run stopped in with its failure reason', () => {
const sections = getOrderedAutofixSections({
...makeState([
makeBlock({message: {metadata: {step: 'root_cause'}}}),
makeBlock({message: {metadata: {step: 'solution'}}}),
]),
status: 'error',
failure_reason: 'out_of_credits',
});

expect(sections.map(s => [s.status, s.failureReason])).toEqual([
['completed', undefined],
['error', 'out_of_credits'],
]);
expect(sections.map(isOutOfCreditsSection)).toEqual([false, true]);
});

it('does not treat other failures as out of credits', () => {
const [section] = getOrderedAutofixSections({
...makeState([makeBlock({message: {metadata: {step: 'root_cause'}}})]),
status: 'error',
failure_reason: 'timeout',
});

expect(section!.status).toBe('error');
expect(isOutOfCreditsSection(section!)).toBe(false);
});

it('marks the synthetic pull_request section as processing while a PR is creating', () => {
const sections = getOrderedAutofixSections({
...makeState([makeBlock({message: {metadata: {step: 'code_changes'}}})]),
Expand Down Expand Up @@ -1254,6 +1284,71 @@ describe('useExplorerAutofix - createPR', () => {
);
});

it('says a push was refused for credits and rechecks credits', async () => {
const errorSpy = jest.spyOn(toast, 'error');
const organization = OrganizationFixture({
features: ['seer-credit-checks', 'token-billed-seer-enabled'],
});
const creditCheck = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/seer/credit-check/`,
body: {hasEnoughCredits: true},
});
MockApiClient.addMockResponse({
url: AUTOFIX_URL,
method: 'GET',
body: {
autofix: {
run_id: 42,
blocks: [],
status: 'completed',
updated_at: '2026-01-01T00:00:00Z',
repo_pr_states: {
'org/repo': {
repo_name: 'org/repo',
branch_name: 'seer/fix',
commit_sha: 'abc1234',
pr_creation_status: 'error',
pr_creation_error: null,
pr_id: 7,
pr_number: 7,
pr_url: 'https://github.com/org/repo/pull/7',
title: 'Fix',
},
},
},
},
});
MockApiClient.addMockResponse({
url: AUTOFIX_URL,
method: 'POST',
statusCode: 402,
body: {
detail:
'Your organization does not have enough credits to continue your Autofix run.',
code: 'seer_out_of_credits',
},
});

const {result} = renderHookWithProviders(
() => ({
autofix: useExplorerAutofix(MOCK_GROUP),
credits: useSeerCreditCheck('autofix_open_pr'),
}),
{organization}
);
await waitFor(() => expect(result.current.autofix.runState?.run_id).toBe(42));
await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(1));

await expect(
act(() => result.current.autofix.createPR(42, 'org/repo'))
).rejects.toThrow();

expect(errorSpy).toHaveBeenCalledWith(
"Seer couldn't push changes to org/repo#7 because your organization is out of credits."
);
await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(2));
});

it('shows an error toast and throws on API error', async () => {
MockApiClient.addMockResponse({
url: AUTOFIX_URL,
Expand Down Expand Up @@ -1719,6 +1814,51 @@ describe('useExplorerAutofix - startStep errors', () => {
);
});

it('says a kickoff was refused for credits, rechecks credits, and paints no failed run', async () => {
const errorSpy = jest.spyOn(toast, 'error');
const organization = OrganizationFixture({
features: ['seer-credit-checks', 'token-billed-seer-enabled'],
});
const creditCheck = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/seer/credit-check/`,
body: {hasEnoughCredits: true},
});
MockApiClient.addMockResponse({
url: AUTOFIX_URL,
method: 'GET',
body: {autofix: null},
});
MockApiClient.addMockResponse({
url: AUTOFIX_URL,
method: 'POST',
statusCode: 402,
body: {
detail: 'Your organization does not have enough credits to start an Autofix run.',
code: 'seer_out_of_credits',
},
});

const {result} = renderHookWithProviders(
() => ({
autofix: useExplorerAutofix(MOCK_GROUP),
credits: useSeerCreditCheck('autofix_root_cause'),
}),
{organization}
);
await waitFor(() => expect(result.current.autofix.isLoading).toBe(false));
await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(1));

await expect(
act(() => result.current.autofix.startStep('root_cause'))
).rejects.toThrow();

expect(errorSpy).toHaveBeenCalledWith(
"Seer couldn't start on the root cause because your organization is out of credits."
);
expect(result.current.autofix.runState).toBeNull();
await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(2));
});

it('falls back to the error state when a kickoff fails with no existing run', async () => {
MockApiClient.addMockResponse({
url: AUTOFIX_URL,
Expand Down
70 changes: 68 additions & 2 deletions static/app/components/events/autofix/useExplorerAutofix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import {useQuery, useQueryClient} from '@tanstack/react-query';
import {useModal} from '@sentry/scraps/modal';
import {toast} from '@sentry/scraps/toast';

import {
getOutOfCreditsPRMessage,
OUT_OF_CREDITS_OPEN_PRS_MESSAGE,
OUT_OF_CREDITS_START_STEP_MESSAGE,
} from 'sentry/components/seer/outOfCredits/messages';
import {useServiceWorker} from 'sentry/serviceWorker/client/serviceWorkerContext';
import type {Group} from 'sentry/types/group';
import type {User} from 'sentry/types/user';
Expand All @@ -12,6 +17,7 @@ import {trackAnalytics} from 'sentry/utils/analytics';
import {apiOptions} from 'sentry/utils/api/apiOptions';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {defined} from 'sentry/utils/defined';
import {seerCreditChecksQueryKey} from 'sentry/utils/seer/useSeerCreditCheck';
import {useApi} from 'sentry/utils/useApi';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useUser} from 'sentry/utils/useUser';
Expand All @@ -25,6 +31,7 @@ import {
type Artifact,
type Block,
type ExplorerCodingAgentState,
type ExplorerFailureReason,
type ExplorerFilePatch,
type RepoPRState,
type SeerExplorerRunId,
Expand Down Expand Up @@ -216,6 +223,7 @@ export interface ExplorerAutofixState {
status: 'processing' | 'completed' | 'error' | 'awaiting_user_input';
updated_at: string;
coding_agents?: Record<string, ExplorerCodingAgentState>;
failure_reason?: ExplorerFailureReason | null;
feature_id?: string | null;
pending_user_input?: {
data: Record<string, unknown>;
Expand Down Expand Up @@ -326,6 +334,19 @@ function isAutofixSetupRequiredError(
);
}

/**
* A step or PR the backend refused because the organization is out of Seer
* credits. The triggers' credit checks fail open and can be out of date, so
* the backend has the final say.
*/
function isOutOfCreditsError(e: unknown): boolean {
const error = e as
| {responseJSON?: {code?: unknown}; status?: number}
| null
| undefined;
return error?.status === 402 && error.responseJSON?.code === 'seer_out_of_credits';
}

const makeErrorExplorerAutofixData = (errorMessage: string): ExplorerAutofixResponse => ({
autofix: {
run_id: 0,
Expand Down Expand Up @@ -415,6 +436,10 @@ export interface AutofixSection {
blocks: Block[];
status: 'processing' | 'completed' | 'error';
step: string;
/**
* Why the run stopped, on the section it stopped in.
*/
failureReason?: ExplorerFailureReason | null;
index?: number;
}

Expand Down Expand Up @@ -460,6 +485,7 @@ function buildSection(

if (currentStep && runState?.status === 'error') {
section.status = 'error';
section.failureReason = runState.failure_reason;
} else if (
(!currentStep && runState?.status !== 'processing') ||
isLastBlockOfSection(blocks[blocks.length - 1]) ||
Expand Down Expand Up @@ -672,6 +698,15 @@ export function isCodingAgentsSection(section: AutofixSection): boolean {
return section.step === 'coding_agents';
}

/**
* The step stopped because the organization ran out of Seer credits. Running it
* again fails the same way until someone adds credits, and the blocks it wrote
* before stopping are still worth showing.
*/
export function isOutOfCreditsSection(section: AutofixSection): boolean {
return section.status === 'error' && section.failureReason === 'out_of_credits';
}

export function isPrIterationPaused(runState: ExplorerAutofixState | null): boolean {
return runState?.pr_iteration_paused === true;
}
Expand Down Expand Up @@ -998,6 +1033,18 @@ export function useExplorerAutofix(
return {runId: getAutofixRunId(e.responseJSON)!, isSingleRun: false};
}

// Recheck every credit check, so the credit gates bring up the
// out-of-credits CTA and hold their buttons back, and say why the step
// didn't start. A refused kickoff paints no failed run: the start card
// offers the CTA instead.
if (isOutOfCreditsError(e)) {
queryClient.invalidateQueries({
queryKey: seerCreditChecksQueryKey(organization),
});
toast.error(OUT_OF_CREDITS_START_STEP_MESSAGE[step]);
throw e;
}

// The kickoff was refused because Seer setup is incomplete. There is no
// failed run to paint, and unlike the in-flight race there is nothing to
// recover by refetching — surface the backend's setup copy (it names the
Expand Down Expand Up @@ -1063,11 +1110,30 @@ export function useExplorerAutofix(
queryKey: explorerAutofixApiOptions(orgSlug, groupId).queryKey,
});
} catch (e: any) {
toast.error(getApiErrorMessage(e, 'Failed to create PR'));
if (!isOutOfCreditsError(e)) {
toast.error(getApiErrorMessage(e, 'Failed to create PR'));
throw e;
}

// Recheck every credit check, so the credit gates bring up the
// out-of-credits CTA and hold their buttons back.
queryClient.invalidateQueries({queryKey: seerCreditChecksQueryKey(organization)});
// A retry for a repo whose PR exists pushes to it rather than opening one.
const repoState = repoName
? queryClient.getQueryData(explorerAutofixApiOptions(orgSlug, groupId).queryKey)
?.json?.autofix?.repo_pr_states?.[repoName]
: undefined;
toast.error(
repoName
? getOutOfCreditsPRMessage(
repoState ?? {repo_name: repoName, pr_number: null}
)
: OUT_OF_CREDITS_OPEN_PRS_MESSAGE
);
throw e;
}
},
[api, orgSlug, groupId, queryClient]
[api, orgSlug, groupId, organization, queryClient]
);

/**
Expand Down
Loading
Loading