Skip to content
Draft
16 changes: 13 additions & 3 deletions static/app/components/events/autofix/autofixCredits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ import {
import {getAutofixNextStep, type AutofixNextStep} from './getAutofixNextStep';
import {
getAutofixArtifactFromSection,
isOutOfCreditsSection,
isPullRequestsSection,
isSingleRunAutofix,
type AutofixSection,
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 Expand Up @@ -73,11 +77,17 @@ export function getAutofixContinueAction({
const section = sections.at(-1);
const nextStep = getAutofixNextStep({sections, runState});

// A single run offers no next step; its only follow-up is opening the chat.
if (!defined(section) || isSingleRunAutofix(runState)) {
if (!defined(section)) {
return null;
}

// A single run offers no next step; its only follow-up is opening the chat.
// One that stopped for credits is retried with a new run, which starts at
// the root cause.
if (isSingleRunAutofix(runState)) {
return isOutOfCreditsSection(section) ? 'root_cause' : null;
}

// An open PR takes feedback even while a run is going, and a failed create is
// retried with another create.
if (isPullRequestsSection(section)) {
Expand Down
169 changes: 169 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 @@ -847,6 +877,35 @@ describe('getOrderedAutofixSections', () => {
);
});

it.each([
['before a root cause', [], [true]],
[
'after the root cause',
[makeBlock({artifacts: [rootCause('H', 'C')]})],
[false, true],
],
[
'while editing',
[
makeBlock({artifacts: [rootCause('H', 'C')]}),
makeBlock({merged_file_patches: [makePatch('org/repo', 'a.py')]}),
],
[false, true],
],
])(
'marks the section a run stopped in %s as out of credits',
(_, blocks, expected) => {
const sections = getOrderedAutofixSections(
makeSingleRun(
[makeBlock({message: {metadata: {step: 'single_run'}}}), ...blocks],
{status: 'error', failure_reason: 'out_of_credits'}
)
);

expect(sections.map(isOutOfCreditsSection)).toEqual(expected);
}
);

it('shows an error on code changes when the run failed after editing', () => {
const sections = getOrderedAutofixSections(
makeSingleRun(
Expand Down Expand Up @@ -1254,6 +1313,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 +1843,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
Loading
Loading