Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
59c351a
feat(autofix): Register autofix-retry-from-step feature flag
isaacwang-sentry Apr 6, 2026
3c52843
feat(autofix): Thread insert_index through explorer API for retry
isaacwang-sentry Apr 6, 2026
f40e273
ref(autofix): Remove unused autofix-retry-from-step feature flag
isaacwang-sentry Apr 6, 2026
de0bc78
fix(test): Add insert_index to trigger_autofix_explorer mock assertion
isaacwang-sentry Apr 7, 2026
f299f7a
test(autofix): Add test for insert_index pass-through in explorer API
isaacwang-sentry Apr 7, 2026
6dcf2a7
feat(autofix): Add retry buttons to Autofix v3 step cards
isaacwang-sentry Apr 6, 2026
4e0d067
fix(autofix): Pass insert_index on retry to truncate downstream steps
isaacwang-sentry Apr 6, 2026
57c9365
ref(autofix): Remove feature flag gating and add retry to RootCauseCard
isaacwang-sentry Apr 6, 2026
b4513d8
fix(autofix): Hide stale PR section after retry-from-step
isaacwang-sentry Apr 6, 2026
4edbec1
feat(autofix): Smart PR state handling after retry-from-step
isaacwang-sentry Apr 6, 2026
14073d2
fix(autofix): Only show PR section when PRs are in sync with code
isaacwang-sentry Apr 7, 2026
106fbf2
fix(autofix): Show creating/updating state when user confirms PR
isaacwang-sentry Apr 7, 2026
0eb10bd
fix(autofix): Show PR card with creating/updating state on confirm
isaacwang-sentry Apr 7, 2026
dbcc16d
fix(autofix): Allow retrying failed PR creation
isaacwang-sentry Apr 7, 2026
c835cea
fix(test): Add blockIndex to AutofixSection test helpers
isaacwang-sentry Apr 7, 2026
9ac0edb
ref(autofix): Remove PR card header retry, add inline error retry
isaacwang-sentry Apr 7, 2026
292ccf9
fix(autofix): Show PR card on creation error for retry
isaacwang-sentry Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/seer/autofix/autofix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ def trigger_autofix_explorer(
stopping_point: AutofixStoppingPoint | None = None,
intelligence_level: Literal["low", "medium", "high"] = "low",
user_context: str | None = None,
insert_index: int | None = None,
) -> int:
"""
Start or continue an Explorer-based autofix run.
Expand Down Expand Up @@ -247,6 +248,7 @@ def trigger_autofix_explorer(
prompt_metadata=prompt_metadata,
artifact_key=artifact_key,
artifact_schema=artifact_schema,
insert_index=insert_index,
)

payload = {
Expand Down
5 changes: 5 additions & 0 deletions src/sentry/seer/endpoints/group_ai_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ class ExplorerAutofixRequestSerializer(CamelSnakeSerializer):
required=False,
help_text="Optional repository name for which to create the pull request. Do not pass a repository name to create pull requests in all relevant repositories.",
)
insert_index = serializers.IntegerField(
required=False,
help_text="Block index to insert at. When provided, truncates blocks after this point for retry-from-step.",
)

def validate(self, data: dict[str, Any]) -> dict[str, Any]:
stopping_point = data.get("stopping_point", None)
Expand Down Expand Up @@ -289,6 +293,7 @@ def _post_explorer(self, request: Request, group: Group) -> Response:
run_id=run_id,
intelligence_level=data["intelligence_level"],
user_context=data.get("user_context"),
insert_index=data.get("insert_index"),
)
return Response({"run_id": run_id}, status=status.HTTP_202_ACCEPTED)
except SeerPermissionError as e:
Expand Down
112 changes: 82 additions & 30 deletions static/app/components/events/autofix/useExplorerAutofix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export function getOrderedArtifactKeys(

export interface AutofixSection {
artifacts: AutofixArtifact[];
blockIndex: number;
messages: Array<Block['message']>;
status: 'processing' | 'completed';
step: string;
Expand Down Expand Up @@ -308,6 +309,7 @@ export function getOrderedAutofixSections(runState: ExplorerAutofixState | null)
// appended to whatever section is in progress (initially an 'unknown' one).
let section: AutofixSection = {
step: 'unknown',
blockIndex: 0,
artifacts: [],
messages: [],
status: 'processing',
Expand All @@ -331,7 +333,8 @@ export function getOrderedAutofixSections(runState: ExplorerAutofixState | null)
}
}

for (const block of blocks) {
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex]!;
// Accumulate file patches globally — they need to be merged across all
// blocks regardless of section boundaries so later patches win per file.
if (block.merged_file_patches?.length) {
Expand All @@ -353,6 +356,7 @@ export function getOrderedAutofixSections(runState: ExplorerAutofixState | null)

section = {
step: metadata.step,
blockIndex,
artifacts: [],
messages: [],
status: 'processing',
Expand All @@ -367,35 +371,44 @@ export function getOrderedAutofixSections(runState: ExplorerAutofixState | null)
// Finalize the last in-progress section.
finalizeSection();

// If there are any PR states, append a synthetic "pull_request" section.
const pullRequests = Object.values(runState?.repo_pr_states ?? {});
if (pullRequests.length) {
sections.push({
step: 'pull_request',
artifacts: [pullRequests],
messages: [],
status: pullRequests.some(
pullRequest => pullRequest.pr_creation_status === 'creating'
)
? 'processing'
: 'completed',
});
}
const hasCompletedCodeChanges = sections.some(
s => s.step === 'code_changes' && s.status === 'completed'
);

if (hasCompletedCodeChanges) {
// Show the PR section when PRs are in sync (View links) or when a PR
// is being created/updated (disabled button with loading text).
// Hide it when PRs are out of sync — the NextStep prompt handles that.
const repoPRStates = runState?.repo_pr_states ?? {};
const pullRequests = Object.values(repoPRStates);
const anyCreating = pullRequests.some(pr => pr.pr_creation_status === 'creating');
const anyError = pullRequests.some(pr => pr.pr_creation_status === 'error');
if (anyCreating || anyError || areAllPRsInSync(blocks, repoPRStates)) {
sections.push({
step: 'pull_request',
blockIndex: blocks.length,
artifacts: [pullRequests],
messages: [],
status: anyCreating ? 'processing' : 'completed',
});
}

const codingAgents = Object.values(runState?.coding_agents ?? {});
if (codingAgents.length) {
sections.push({
step: 'coding_agents',
artifacts: [codingAgents],
messages: [],
status: codingAgents.some(
codingAgent =>
codingAgent.status === CodingAgentStatus.PENDING ||
codingAgent.status === CodingAgentStatus.RUNNING
)
? 'processing'
: 'completed',
});
const codingAgents = Object.values(runState?.coding_agents ?? {});
if (codingAgents.length) {
sections.push({
step: 'coding_agents',
blockIndex: blocks.length,
artifacts: [codingAgents],
messages: [],
status: codingAgents.some(
codingAgent =>
codingAgent.status === CodingAgentStatus.PENDING ||
codingAgent.status === CodingAgentStatus.RUNNING
)
? 'processing'
: 'completed',
});
}
}

return sections;
Expand All @@ -421,6 +434,36 @@ export function isCodingAgentsSection(section: AutofixSection): boolean {
return section.step === 'coding_agents';
}

/**
* Checks if all PRs are in sync with the current code changes.
* A PR is in sync when its commit_sha matches the pr_commit_shas on the
* last block that has merged patches for that repo.
*/
export function areAllPRsInSync(
blocks: Block[],
repoPRStates: Record<string, RepoPRState>
): boolean {
const pullRequests = Object.values(repoPRStates);
if (!pullRequests.length) {
return false;
}
return pullRequests.every(pr => {
if (pr.pr_creation_status === 'creating') {
return false;
}
if (!pr.pr_number || !pr.pr_url || !pr.commit_sha) {
return false;
}
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block?.merged_file_patches?.some(p => p.repo_name === pr.repo_name)) {
return block.pr_commit_shas?.[pr.repo_name] === pr.commit_sha;
}
}
return false;
});
}

export type AutofixArtifact =
| Artifact<unknown>
| ExplorerFilePatch[]
Expand Down Expand Up @@ -548,7 +591,12 @@ export function useExplorerAutofix(
* @param runId - Optional run ID to continue an existing run
*/
const startStep = useCallback(
async (step: AutofixExplorerStep, runId?: number, userContext?: string) => {
async (
step: AutofixExplorerStep,
runId?: number,
userContext?: string,
insertIndex?: number
) => {
setWaitingForResponse(true);

try {
Expand All @@ -562,6 +610,10 @@ export function useExplorerAutofix(
data.user_context = userContext;
}

if (defined(insertIndex)) {
data.insert_index = insertIndex;
}

const response = await api.requestPromise(
getApiUrl('/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', {
path: {organizationIdOrSlug: orgSlug, issueId: groupId},
Expand Down
129 changes: 127 additions & 2 deletions static/app/components/events/autofix/v3/autofixCards.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ jest.mock('sentry/views/seerExplorer/fileDiffViewer', () => ({
function makeSection(
step: string,
status: AutofixSection['status'],
artifacts: AutofixArtifact[]
artifacts: AutofixArtifact[],
blockIndex = 0
): AutofixSection {
return {step, artifacts, messages: [], status};
return {step, blockIndex, artifacts, messages: [], status};
}

function makePatch(repoName: string, path: string): ExplorerFilePatch {
Expand Down Expand Up @@ -618,3 +619,127 @@ describe('CodingAgentCard', () => {
expect(screen.getByText('running')).toBeInTheDocument();
});
});

describe('Retry button', () => {
const autofixWithRun = {
...mockAutofix,
runState: {run_id: 42} as any,
isPolling: false,
};

it('shows retry button on completed SolutionCard', () => {
const artifact = makeSolutionArtifact({
one_line_summary: 'Fix the bug',
steps: [{title: 'Step 1', description: 'Do something'}],
});

render(
<SolutionCard
autofix={autofixWithRun}
section={makeSection('solution', 'completed', [artifact])}
/>
);

expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
});

it('calls startStep with root_cause, runId, and blockIndex on RootCauseCard retry click', async () => {
const startStep = jest.fn();
const artifact = makeRootCauseArtifact({
one_line_description: 'Null pointer',
five_whys: ['why1'],
});

render(
<RootCauseCard
autofix={{...autofixWithRun, startStep}}
section={makeSection('root_cause', 'completed', [artifact], 0)}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
expect(startStep).toHaveBeenCalledWith('root_cause', 42, undefined, 0);
});

it('calls startStep with solution, runId, and blockIndex on SolutionCard retry click', async () => {
const startStep = jest.fn();
const artifact = makeSolutionArtifact({
one_line_summary: 'Fix the bug',
steps: [{title: 'Step 1', description: 'Do something'}],
});

render(
<SolutionCard
autofix={{...autofixWithRun, startStep}}
section={makeSection('solution', 'completed', [artifact], 3)}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
expect(startStep).toHaveBeenCalledWith('solution', 42, undefined, 3);
});

it('calls startStep with code_changes, runId, and blockIndex on CodeChangesCard retry click', async () => {
const startStep = jest.fn();

render(
<CodeChangesCard
autofix={{...autofixWithRun, startStep}}
section={makeSection(
'code_changes',
'completed',
[[makePatch('org/repo', 'src/app.py')]],
5
)}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
expect(startStep).toHaveBeenCalledWith('code_changes', 42, undefined, 5);
});

it('does not show retry button when section is processing', () => {
const artifact = makeSolutionArtifact({
one_line_summary: 'Fix the bug',
steps: [{title: 'Step 1', description: 'Do something'}],
});

render(
<SolutionCard
autofix={autofixWithRun}
section={makeSection('solution', 'processing', [artifact])}
/>
);

expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument();
});

it('does not show retry button on SolutionCard when artifact data is null', () => {
const artifact = makeSolutionArtifact(null);

render(
<SolutionCard
autofix={autofixWithRun}
section={makeSection('solution', 'completed', [artifact])}
/>
);

expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument();
});

it('disables retry button when isPolling is true', () => {
const artifact = makeSolutionArtifact({
one_line_summary: 'Fix the bug',
steps: [{title: 'Step 1', description: 'Do something'}],
});

render(
<SolutionCard
autofix={{...autofixWithRun, isPolling: true}}
section={makeSection('solution', 'completed', [artifact])}
/>
);

expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled();
});
});
Loading
Loading