diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 2e0898cc375f7c..0901b60a14b85f 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -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. @@ -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 = { diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index d2ccb4f613c8d5..a9c3bc6fa20bf8 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -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) @@ -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: diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index 5f81f93b13a545..1316c140527cc3 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -277,6 +277,7 @@ export function getOrderedArtifactKeys( export interface AutofixSection { artifacts: AutofixArtifact[]; + blockIndex: number; messages: Array; status: 'processing' | 'completed'; step: string; @@ -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', @@ -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) { @@ -353,6 +356,7 @@ export function getOrderedAutofixSections(runState: ExplorerAutofixState | null) section = { step: metadata.step, + blockIndex, artifacts: [], messages: [], status: 'processing', @@ -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; @@ -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 +): 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 | ExplorerFilePatch[] @@ -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 { @@ -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}, diff --git a/static/app/components/events/autofix/v3/autofixCards.spec.tsx b/static/app/components/events/autofix/v3/autofixCards.spec.tsx index 7cf3e2e5c4bc8f..38bced5003c3f2 100644 --- a/static/app/components/events/autofix/v3/autofixCards.spec.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.spec.tsx @@ -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 { @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled(); + }); +}); diff --git a/static/app/components/events/autofix/v3/autofixCards.tsx b/static/app/components/events/autofix/v3/autofixCards.tsx index a9fc9f309dfbcc..c6227f4bb7d238 100644 --- a/static/app/components/events/autofix/v3/autofixCards.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.tsx @@ -47,10 +47,22 @@ export function RootCauseCard({autofix, section}: AutofixCardProps) { return isRootCauseArtifact(sectionArtifact) ? sectionArtifact : null; }, [section]); - const {startStep} = autofix; + const {startStep, runState, isPolling} = autofix; + const runId = runState?.run_id; return ( - } title={t('Root Cause')}> + } + title={t('Root Cause')} + trailingItems={ + section.status === 'completed' && artifact?.data ? ( + startStep('root_cause', runId, undefined, section.blockIndex)} + disabled={isPolling} + /> + ) : undefined + } + > {section.status === 'processing' ? ( } title={t('Plan')}> + } + title={t('Plan')} + trailingItems={ + section.status === 'completed' && artifact?.data ? ( + startStep('solution', runId, undefined, section.blockIndex)} + disabled={isPolling} + /> + ) : undefined + } + > {section.status === 'processing' ? ( } title={t('Code Changes')}> + } + title={t('Code Changes')} + trailingItems={ + section.status === 'completed' && patchesByRepo.size > 0 ? ( + + startStep('code_changes', runId, undefined, section.blockIndex) + } + disabled={isPolling} + /> + ) : undefined + } + > {section.status === 'processing' ? ( { const sectionArtifact = getAutofixArtifactFromSection(section); return isPullRequestsArtifact(sectionArtifact) ? sectionArtifact : null; }, [section]); + const {createPR, runState, isPolling} = autofix; + const runId = runState?.run_id; + return ( } title={t('Pull Requests')}> {artifact?.map(pullRequest => { if (pullRequest.pr_creation_status === 'creating') { + const isUpdating = !!(pullRequest.pr_number && pullRequest.pr_url); return ( ); } @@ -289,8 +331,13 @@ export function PullRequestsCard({section}: AutofixCardProps) { } return ( - ); })} @@ -363,17 +410,39 @@ export function CodingAgentCard({section}: AutofixCardProps) { ); } -interface ArtifactCardProps { +interface RetryButtonProps { + disabled: boolean; + onClick: () => void; +} + +function RetryButton({onClick, disabled}: RetryButtonProps) { + return ( +