diff --git a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx index 2d4e0e0fb152fe..201f591b39e26c 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx @@ -1,19 +1,26 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {DiffFileType, DiffLineType} from 'sentry/components/events/autofix/types'; import { collectPatches, + getOrderedAutofixSections, isCodeChangesArtifact, isCodingAgentsArtifact, + isLastStepPrIteration, + isPrIterationBlock, isPullRequestsArtifact, isRootCauseArtifact, + isRunValidForPrIteration, isSolutionArtifact, useExplorerAutofix, + type ExplorerAutofixState, type RootCauseArtifact, type SolutionArtifact, } from 'sentry/components/events/autofix/useExplorerAutofix'; -import type {Artifact, ExplorerFilePatch} from 'sentry/views/seerExplorer/types'; +import type {Artifact, Block, ExplorerFilePatch} from 'sentry/views/seerExplorer/types'; jest.mock('sentry/actionCreators/indicator'); @@ -391,6 +398,260 @@ describe('collectPatches', () => { }); }); +describe('getOrderedAutofixSections', () => { + let blockId = 0; + + function makeBlock( + overrides: Omit, 'message'> & {message?: Partial} + ) { + const {message, ...rest} = overrides; + return { + id: `block-${blockId++}`, + timestamp: '2026-01-01T00:00:00Z', + message: { + content: 'hello', + role: 'assistant', + ...message, + }, + ...rest, + } as Block; + } + + function makePatch(repoName: string, path: string, diff = 'diff'): ExplorerFilePatch { + return { + repo_name: repoName, + diff, + patch: { + added: 1, + removed: 0, + path, + source_file: path, + target_file: path, + type: DiffFileType.MODIFIED, + hunks: [], + }, + }; + } + + function makeState(blocks: Block[]): ExplorerAutofixState { + return { + run_id: 1, + status: 'completed', + updated_at: '2026-01-01T00:00:00Z', + blocks, + }; + } + + it('returns an empty array for null state or no blocks', () => { + expect(getOrderedAutofixSections(null)).toEqual([]); + expect(getOrderedAutofixSections(makeState([]))).toEqual([]); + }); + + it('groups blocks into sections at each step marker', () => { + const sections = getOrderedAutofixSections( + makeState([ + makeBlock({message: {metadata: {step: 'root_cause'}}}), + makeBlock({}), + makeBlock({message: {metadata: {step: 'solution'}}}), + ]) + ); + + expect(sections.map(s => s.step)).toEqual(['root_cause', 'solution']); + expect(sections[0]!.blocks).toHaveLength(2); + expect(sections[1]!.blocks).toHaveLength(1); + }); + + it('merges all code_changes blocks into a single section with the cumulative diff', () => { + const sections = getOrderedAutofixSections( + makeState([ + makeBlock({ + message: {metadata: {step: 'code_changes'}}, + merged_file_patches: [makePatch('org/repo', 'a.py', 'first diff')], + }), + makeBlock({ + message: {metadata: {step: 'code_changes'}}, + merged_file_patches: [makePatch('org/repo', 'b.py', 'second diff')], + }), + ]) + ); + + // Consecutive code_changes blocks collapse into one section that carries the + // cumulative patch set merged across all of its blocks. + expect(sections).toHaveLength(1); + expect(sections[0]!.step).toBe('code_changes'); + expect(sections[0]!.artifacts).toEqual([ + [ + makePatch('org/repo', 'a.py', 'first diff'), + makePatch('org/repo', 'b.py', 'second diff'), + ], + ]); + }); + + it('folds consecutive pr_iteration blocks into the single code_changes section', () => { + const sections = getOrderedAutofixSections( + makeState([ + makeBlock({ + message: {metadata: {step: 'pr_iteration', iteration_index: '1'}}, + merged_file_patches: [makePatch('org/repo', 'a.py')], + }), + makeBlock({ + message: {metadata: {step: 'pr_iteration', iteration_index: '2'}}, + merged_file_patches: [makePatch('org/repo', 'b.py')], + }), + ]) + ); + + // pr_iteration work is folded into the one code_changes section; both + // iteration blocks and their merged patches live there. + expect(sections).toHaveLength(1); + expect(sections[0]!.step).toBe('code_changes'); + expect(sections[0]!.blocks.map(b => b.message.metadata?.iteration_index)).toEqual([ + '1', + '2', + ]); + expect(sections[0]!.artifacts).toEqual([ + [makePatch('org/repo', 'a.py'), makePatch('org/repo', 'b.py')], + ]); + }); + + it('merges patches for the same file within a section, last write wins', () => { + const sections = getOrderedAutofixSections( + makeState([ + makeBlock({ + message: {metadata: {step: 'code_changes'}}, + merged_file_patches: [makePatch('org/repo', 'a.py', 'old')], + }), + makeBlock({ + merged_file_patches: [makePatch('org/repo', 'a.py', 'new')], + }), + ]) + ); + + expect(sections).toHaveLength(1); + expect(sections[0]!.artifacts).toEqual([[makePatch('org/repo', 'a.py', 'new')]]); + }); + + it('does not push an empty patch artifact for a code-change section with no patches', () => { + const sections = getOrderedAutofixSections( + makeState([makeBlock({message: {metadata: {step: 'pr_iteration'}}})]) + ); + + expect(sections).toHaveLength(1); + expect(sections[0]!.artifacts).toEqual([]); + }); + + it('appends a synthetic pull_request section from repo_pr_states', () => { + const prState = { + repo_name: 'org/repo', + pr_number: 42, + pr_url: 'https://github.com/org/repo/pull/42', + branch_name: 'fix/issue', + commit_sha: 'abc123', + pr_creation_error: null, + pr_creation_status: 'completed', + pr_id: 1, + title: 'Fix issue', + } as const; + + const sections = getOrderedAutofixSections({ + ...makeState([makeBlock({message: {metadata: {step: 'code_changes'}}})]), + repo_pr_states: {'org/repo': prState}, + }); + + expect(sections.map(s => s.step)).toEqual(['code_changes', 'pull_request']); + const prSection = sections[sections.length - 1]!; + expect(prSection.status).toBe('completed'); + expect(prSection.artifacts).toEqual([[prState]]); + }); + + it('marks the synthetic pull_request section as processing while a PR is creating', () => { + const sections = getOrderedAutofixSections({ + ...makeState([makeBlock({message: {metadata: {step: 'code_changes'}}})]), + repo_pr_states: { + 'org/repo': { + repo_name: 'org/repo', + pr_creation_status: 'creating', + } as any, + }, + }); + + expect(sections[sections.length - 1]!.status).toBe('processing'); + }); +}); + +describe('isPrIterationBlock', () => { + function block(metadata?: Record): Block { + return { + id: 'block-1', + timestamp: '2026-01-01T00:00:00Z', + message: {content: 'hello', role: 'assistant', metadata}, + } as Block; + } + + it('is true only for blocks whose step is pr_iteration', () => { + expect(isPrIterationBlock(block({step: 'pr_iteration'}))).toBe(true); + expect(isPrIterationBlock(block({step: 'code_changes'}))).toBe(false); + expect(isPrIterationBlock(block())).toBe(false); + }); +}); + +describe('isRunValidForPrIteration', () => { + it('is true only when the autofix-pr-iteration feature is enabled', () => { + expect( + isRunValidForPrIteration(OrganizationFixture({features: ['autofix-pr-iteration']})) + ).toBe(true); + expect(isRunValidForPrIteration(OrganizationFixture({features: []}))).toBe(false); + }); +}); + +describe('isLastStepPrIteration', () => { + let blockId = 0; + function block(step?: string): Block { + return { + id: `block-${blockId++}`, + timestamp: '2026-01-01T00:00:00Z', + message: { + content: 'hello', + role: 'assistant', + metadata: step ? {step} : undefined, + }, + } as Block; + } + function state(blocks: Block[]): ExplorerAutofixState { + return { + run_id: 1, + status: 'completed', + updated_at: '2026-01-01T00:00:00Z', + blocks, + }; + } + + it('is true when the last block carrying a step is pr_iteration', () => { + expect( + isLastStepPrIteration(state([block('code_changes'), block('pr_iteration')])) + ).toBe(true); + }); + + it('ignores trailing step-less blocks when finding the last step', () => { + expect( + isLastStepPrIteration( + state([block('pr_iteration'), block(undefined), block(undefined)]) + ) + ).toBe(true); + }); + + it('is false when the last step is not pr_iteration', () => { + expect( + isLastStepPrIteration(state([block('pr_iteration'), block('code_changes')])) + ).toBe(false); + }); + + it('is false when there are no blocks with a step or no run state', () => { + expect(isLastStepPrIteration(state([block(undefined)]))).toBe(false); + expect(isLastStepPrIteration(null)).toBe(false); + }); +}); + describe('useExplorerAutofix - createPR', () => { const GROUP_ID = '123'; const AUTOFIX_URL = `/organizations/org-slug/issues/${GROUP_ID}/autofix/`; diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index 83e67fe6bda981..d563977492fca0 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -16,6 +16,7 @@ import { needsGitHubAuth, type CodingAgentIntegration, } from 'sentry/components/events/autofix/useAutofix'; +import type {Organization} from 'sentry/types/organization'; import {isArrayOf, isString} from 'sentry/types/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {apiOptions} from 'sentry/utils/api/apiOptions'; @@ -44,7 +45,11 @@ interface CodingAgentError { message: string; } -export type AutofixExplorerStep = 'root_cause' | 'solution' | 'code_changes'; +export type AutofixExplorerStep = + | 'root_cause' + | 'solution' + | 'code_changes' + | 'pr_iteration'; /** * Artifact data types matching the backend Pydantic schemas. @@ -230,14 +235,72 @@ export interface AutofixSection { index?: number; } +function sectionStepFor(step: string): string { + return step === 'pr_iteration' ? 'code_changes' : step; +} + +function mergeFilePatches(blocks: Block[]): ExplorerFilePatch[] { + const mergedByFile = new Map(); + for (const block of blocks) { + for (const patch of block.merged_file_patches ?? []) { + mergedByFile.set(`${patch.repo_name}:${patch.patch.path}`, patch); + } + } + return Array.from(mergedByFile.values()); +} + +/** + * Builds a single section from the blocks that belong to it. + * + * The section's artifacts are the inline artifacts carried by its blocks plus, + * for the code_changes section, the cumulative file-patch diff merged from all + * of its blocks (code changes and every pr_iteration folded in). + */ +function buildSection( + step: string, + index: number, + blocks: Block[], + runState: ExplorerAutofixState | null +): AutofixSection { + const artifacts: AutofixArtifact[] = blocks.flatMap(block => block.artifacts ?? []); + + const section: AutofixSection = { + index, + step, + blocks, + artifacts, + status: 'processing', + }; + + if ( + runState?.status !== 'processing' || + isLastBlockOfSection(blocks[blocks.length - 1]) || + artifacts.length > 0 + ) { + section.status = 'completed'; + } + + if (isCodeChangesSection(section)) { + const patches = mergeFilePatches(blocks); + if (patches.length) { + artifacts.push(patches); + } + } + + return section; +} + /** * Groups a flat list of autofix blocks into ordered sections. * * Blocks arrive as a flat stream from the backend. Each block may carry a - * `metadata.step` field that signals the start of a new logical section - * (e.g. "root_cause", "code_changes", "pull_request"). This function walks - * the blocks in order, splitting them into sections at each step boundary, - * and attaches the relevant artifacts (file patches, PR states) to each section. + * `metadata.step` field that signals which logical section it belongs to + * (e.g. "root_cause", "code_changes"). The first block always carries a step, + * and a step-less block belongs to whichever section is currently open. We + * first bucket the blocks by section — folding `pr_iteration` work into the + * single `code_changes` section — then build each section from its blocks, + * attaching the relevant artifacts (file patches, PR states). At most one + * section exists per step. */ export function getOrderedAutofixSections(runState: ExplorerAutofixState | null) { const blocks = runState?.blocks ?? []; @@ -245,85 +308,33 @@ export function getOrderedAutofixSections(runState: ExplorerAutofixState | null) return []; } - // Accumulates file patches across all blocks, keyed by "repo:path". - // Patches are merged globally (later patches for the same file overwrite - // earlier ones) and snapshotted into the code_changes section when it finalizes. - const mergedByFile = new Map(); - - const sections: AutofixSection[] = []; + // Bucket blocks by section step, preserving first-seen order. Each bucket + // also records the index of its first block, used as the reset/restart point. + const buckets = new Map(); - // The "current" section being built. Blocks without a step marker are - // appended to whatever section is in progress (initially an 'unknown' one). - let section: AutofixSection = { - step: 'unknown', - artifacts: [], - blocks: [], - status: 'processing', - }; - - // Closes the current section and pushes it to `sections` (if non-empty). - function finalizeSection({forceCompletion}: {forceCompletion: boolean}) { - if (section.blocks.length) { - if ( - forceCompletion || - // Mark the section as completed if the last message is a terminal marker. - isLastBlockOfSection(section.blocks[section.blocks.length - 1]) || - // We have an artifact for the section so good enough to mark it as completed - section.artifacts.length > 0 - ) { - section.status = 'completed'; - } - - if (section.status === 'completed' && section.step === 'code_changes') { - // Snapshot the accumulated file patches as an artifact for this section. - section.artifacts.push(Array.from(mergedByFile.values())); - } - - sections.push(section); - } - } + // The section a step-less block belongs to. The first block always carries a + // step, so this is set before any block is bucketed. + let currentStep = ''; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]!; - // 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) { - for (const patch of block.merged_file_patches) { - const key = `${patch.repo_name}:${patch.patch.path}`; - mergedByFile.set(key, patch); - } + const step = block.message.metadata?.step; + if (defined(step)) { + currentStep = sectionStepFor(step); } - const message = block.message; - - // A step marker means this block starts a new section. - // Finalize the previous section and start a fresh one. - const metadata = message.metadata; - if (defined(metadata) && defined(metadata.step)) { - if (metadata.step !== section.step) { - // since there's a new section coming up, this section must be compelete - finalizeSection({forceCompletion: true}); - } - - section = { - index: i, - step: metadata.step, - artifacts: [], - blocks: [], - status: 'processing', - }; + const bucket = buckets.get(currentStep); + if (bucket) { + bucket.blocks.push(block); + } else { + buckets.set(currentStep, {blocks: [block], index: i}); } - - // Append the block's message and any inline artifacts to the current section. - section.blocks.push(block); - section.artifacts.push(...(block.artifacts ?? [])); } - // Finalize the last in-progress section. - finalizeSection({ - // run is complete so last section must be complete as well - forceCompletion: runState?.status !== 'processing', - }); + const sections: AutofixSection[] = Array.from(buckets.entries()).map( + ([step, {blocks: sectionBlocks, index}]) => + buildSection(step, index, sectionBlocks, runState) + ); // If there are any PR states, append a synthetic "pull_request" section. const pullRequests = Object.values(runState?.repo_pr_states ?? {}); @@ -371,6 +382,10 @@ export function isCodeChangesSection(section: AutofixSection): boolean { return section.step === 'code_changes'; } +export function isPrIterationBlock(block: Block): boolean { + return block.message.metadata?.step === 'pr_iteration'; +} + export function isPullRequestsSection(section: AutofixSection): boolean { return section.step === 'pull_request'; } @@ -379,6 +394,20 @@ export function isCodingAgentsSection(section: AutofixSection): boolean { return section.step === 'coding_agents'; } +export function isRunValidForPrIteration(organization: Organization): boolean { + return organization.features.includes('autofix-pr-iteration'); +} + +export function isLastStepPrIteration(runState: ExplorerAutofixState | null): boolean { + // pr_iteration is always the last work to run, so if the most recent block + // with a step came from one, the run is in the pr_iteration phase (whether it + // completed or errored). + const lastBlock = runState?.blocks.findLast(block => + defined(block.message.metadata?.step) + ); + return defined(lastBlock) && isPrIterationBlock(lastBlock); +} + export type AutofixArtifact = | Artifact | ExplorerFilePatch[] diff --git a/static/app/components/events/autofix/v3/autofixCards.spec.tsx b/static/app/components/events/autofix/v3/autofixCards.spec.tsx index d2fac07a201191..9bb149586adbe6 100644 --- a/static/app/components/events/autofix/v3/autofixCards.spec.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.spec.tsx @@ -1,3 +1,5 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {CodingAgentProvider} from 'sentry/components/events/autofix/types'; @@ -23,6 +25,8 @@ jest.mock('sentry/views/seerExplorer/components/fileDiffViewer', () => ({ FileDiffViewer: () =>
, })); +const prIterationOrganization = OrganizationFixture({features: ['autofix-pr-iteration']}); + function makeSection( step: string, status: AutofixSection['status'], @@ -40,6 +44,31 @@ function makeAssistantBlock(content: string | null): AutofixSection['blocks'][nu }; } +function makePrIterationBlock( + iterationIndex: number, + feedback: {text: string; timestamp?: string; user?: any} +): AutofixSection['blocks'][number] { + return { + id: `block-pr-${iterationIndex}`, + timestamp: '2026-01-01T00:00:00Z', + message: { + role: 'user', + content: null, + metadata: { + step: 'pr_iteration', + iteration_index: String(iterationIndex), + feedback: JSON.stringify({ + text: feedback.text, + timestamp: feedback.timestamp, + source: feedback.user + ? {type: 'user-ui', user: feedback.user} + : {type: 'user-ui'}, + }), + }, + }, + }; +} + function makePatch(repoName: string, path: string): ExplorerFilePatch { return { repo_name: repoName, @@ -354,6 +383,7 @@ describe('ArtifactCard', () => { it('renders single file in single repo', () => { render( { it('renders multiple files in single repo', () => { render( { it('renders multiple files in multiple repos', () => { render( { it('renders repository name labels', () => { render( { it('renders card shell when no code changes artifact found', () => { render( @@ -436,6 +470,7 @@ describe('ArtifactCard', () => { it('copies markdown when copy button is clicked', async () => { render( { it('does not show copy button when no patches', () => { render( @@ -478,6 +514,7 @@ describe('ArtifactCard', () => { render( @@ -506,6 +543,7 @@ describe('ArtifactCard', () => { render( @@ -521,6 +559,7 @@ describe('ArtifactCard', () => { it('renders loading state when processing, not error', () => { render( @@ -537,6 +576,7 @@ describe('ArtifactCard', () => { it('does not render file diff viewers in error state', () => { render( @@ -548,6 +588,7 @@ describe('ArtifactCard', () => { it('surfaces the agent explanation when no patches but a final message exists', () => { render( { it('opens the context prompt from the explanation state', async () => { render( { ).not.toBeInTheDocument(); }); + it('opens PR iteration feedback from explanation state when a PR exists', async () => { + const startStepMock = jest.fn(); + const autofixWithPR: ReturnType = { + ...mockAutofixWithRunState, + startStep: startStepMock, + runState: { + run_id: 123, + blocks: [], + status: 'completed', + updated_at: '2026-01-01T00:00:00Z', + repo_pr_states: {'org/repo': makePR()}, + }, + }; + + render( + , + {organization: prIterationOrganization} + ); + + await userEvent.click(screen.getByRole('button', {name: 'Add context & retry'})); + + expect( + screen.getByText('Anything else you want to see on your PR?') + ).toBeInTheDocument(); + expect( + screen.queryByText('What additional context should Seer use?') + ).not.toBeInTheDocument(); + + await userEvent.type(screen.getByRole('textbox'), 'Try the other repo'); + await userEvent.click(screen.getByRole('button', {name: 'Submit'})); + + expect(startStepMock).toHaveBeenCalledWith('pr_iteration', { + runId: 123, + userContext: 'Try the other repo', + }); + }); + it('falls back to the generic failure copy when there is no explanation', () => { render( { screen.queryByText("Seer proposed a fix but couldn't apply it automatically") ).not.toBeInTheDocument(); }); + + it('silently ignores pr_iteration blocks with an unrecognized source type', () => { + const block: AutofixSection['blocks'][number] = { + id: 'block-unknown', + timestamp: '2026-01-01T00:00:00Z', + message: { + role: 'user', + content: null, + metadata: { + step: 'pr_iteration', + iteration_index: '0', + feedback: JSON.stringify({text: 'ignored', source: {type: 'mystery'}}), + }, + }, + }; + render( + + ); + + expect(screen.queryByText('Feedback')).not.toBeInTheDocument(); + }); + + it('renders feedback from pr_iteration blocks', () => { + render( + , + {organization: prIterationOrganization} + ); + + expect(screen.getByText('Feedback')).toBeInTheDocument(); + expect(screen.getByText('"Add a test for this"')).toBeInTheDocument(); + }); + + it('does not render iteration feedback without the feature flag', () => { + render( + + ); + + expect(screen.queryByText('Feedback')).not.toBeInTheDocument(); + expect(screen.queryByText('"Add a test for this"')).not.toBeInTheDocument(); + expect(screen.queryByText(/- Latest/)).not.toBeInTheDocument(); + }); + + it('renders a one-based version tag for the latest iteration', () => { + render( + , + {organization: prIterationOrganization} + ); + + // iteration_index is zero-based; the latest (1) renders as v2. + expect(screen.getByText('v2 - Latest')).toBeInTheDocument(); + }); + + it('does not render a version tag without iterations', () => { + render( + + ); + + expect(screen.queryByText(/- Latest/)).not.toBeInTheDocument(); + }); + + it('shows the iterating loading message when processing a pr_iteration', () => { + render( + , + {organization: prIterationOrganization} + ); + + expect(screen.getByText('Iterating on PR…')).toBeInTheDocument(); + expect(screen.queryByText('Implementing changes…')).not.toBeInTheDocument(); + }); }); describe('PullRequestsCard', () => { diff --git a/static/app/components/events/autofix/v3/codeChangesCard.tsx b/static/app/components/events/autofix/v3/codeChangesCard.tsx index edb693eb687085..2fa2df2129ef9f 100644 --- a/static/app/components/events/autofix/v3/codeChangesCard.tsx +++ b/static/app/components/events/autofix/v3/codeChangesCard.tsx @@ -1,14 +1,19 @@ import {Fragment, useMemo} from 'react'; +import {UserAvatar} from '@sentry/scraps/avatar'; +import {Tag} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; import {Markdown} from '@sentry/scraps/markdown'; import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; import { collectPatches, getAutofixArtifactFromSection, isCodeChangesArtifact, + isPrIterationBlock, type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; @@ -16,19 +21,88 @@ import {ArtifactCard} from 'sentry/components/events/autofix/v3/artifactCard'; import {ArtifactDetails} from 'sentry/components/events/autofix/v3/artifactDetails'; import {ArtifactLoadingDetails} from 'sentry/components/events/autofix/v3/artifactLoadingDetails'; import {AutofixResetPrompt} from 'sentry/components/events/autofix/v3/autofixResetPrompt'; +import {PrIterationFeedbackForm} from 'sentry/components/events/autofix/v3/prIterationFeedbackForm'; import {useResetAutofixStep} from 'sentry/components/events/autofix/v3/useResetAutofixStep'; import {artifactToMarkdown} from 'sentry/components/events/autofix/v3/utils'; +import {TimeSince} from 'sentry/components/timeSince'; import {IconCode} from 'sentry/icons/iconCode'; +import {IconGithub} from 'sentry/icons/iconGithub'; import {IconRefresh} from 'sentry/icons/iconRefresh'; import {t, tn} from 'sentry/locale'; +import type {User} from 'sentry/types/user'; +import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; +import {useOrganization} from 'sentry/utils/useOrganization'; import {FileDiffViewer} from 'sentry/views/seerExplorer/components/fileDiffViewer'; interface CodeChangesCardProps { autofix: ReturnType; + groupId: string; section: AutofixSection; } +interface BaseFeedback { + iterationIndex: number; + text: string; + timestamp?: string; +} + +interface UserUiFeedback extends BaseFeedback { + sourceType: 'user-ui'; + user?: User | null; +} + +interface GithubPrCommentFeedback extends BaseFeedback { + sourceType: 'github-pr-comment'; + commentUrl?: string; + githubUsername?: string; +} + +type IterationFeedback = UserUiFeedback | GithubPrCommentFeedback; + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** + * Feedback is stored as a JSON object (`{text, source, timestamp}`), where + * `source` identifies who submitted it: `{type: 'user-ui', user_id, user}` from + * the Sentry UI (the backend resolves `user_id` into a serialized `user`) or + * `{type: 'github-pr-comment', comment}` from an `@sentry` PR comment, where + * `comment` is the raw GitHub comment payload (we read `comment.user.login` for + * attribution and `comment.html_url` to link back to it). + * + * We mux on `source.type` so each variant produces its own discriminated + * `IterationFeedback`, which `FeedbackItem` then renders per-type. Source types + * we don't recognize return `null` so a backend change can roll out ahead of the + * frontend without rendering anything unexpected. + */ +function parseFeedback( + raw: string +): DistributiveOmit | null { + const parsed: { + text: string; + source?: { + type: string; + comment?: {html_url?: string; user?: {login: string}}; + user?: User; + }; + timestamp?: string; + } = JSON.parse(raw); + const base = {text: parsed.text, timestamp: parsed.timestamp}; + switch (parsed.source?.type) { + case 'user-ui': + return {...base, sourceType: 'user-ui', user: parsed.source?.user}; + case 'github-pr-comment': + return { + ...base, + sourceType: 'github-pr-comment', + githubUsername: parsed.source?.comment?.user?.login, + commentUrl: parsed.source?.comment?.html_url, + }; + default: + return null; + } +} + /** * When the coding step finishes without producing any patches, the agent often * still leaves a final assistant message explaining why — e.g. the real fix is a @@ -50,7 +124,64 @@ function getFinalExplanation(section: AutofixSection): string | null { return null; } -export function CodeChangesCard({autofix, section}: CodeChangesCardProps) { +export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProps) { + const organization = useOrganization(); + const hasPrIterationFeature = organization.features.includes('autofix-pr-iteration'); + + // PR iterations are folded into this section's blocks. Surface the feedback + // that drove each one — the cumulative diff is already merged into the + // section's code-change artifact by getOrderedAutofixSections. Gated behind + // the PR iteration feature; when it's off we render the card as if no + // iterations exist. + const feedback = useMemo( + () => + hasPrIterationFeature + ? section.blocks.filter(isPrIterationBlock).flatMap(block => { + const metadata = block.message.metadata; + const value = metadata?.feedback; + const iterationIndex = metadata?.iteration_index; + if (!value || iterationIndex === undefined) { + return []; + } + const parsed = parseFeedback(value); + if (!parsed) { + return []; + } + return [{...parsed, iterationIndex: Number(iterationIndex)}]; + }) + : [], + [section.blocks, hasPrIterationFeature] + ); + + const latestIterationIndex = useMemo( + () => + feedback.reduce( + (max, item) => + max === null ? item.iterationIndex : Math.max(max, item.iterationIndex), + null + ), + [feedback] + ); + + const isIterating = + hasPrIterationFeature && + section.status === 'processing' && + section.blocks.some(isPrIterationBlock); + + // While processing, only replay the assistant output from the current + // in-progress step. Steps (the original coding step plus each PR iteration) + // are folded into this section's blocks; the first block of each step carries + // a `step` marker and the rest inherit it, so slice from the latest marker to + // avoid replaying earlier, already-finished steps. + const loadingBlocks = useMemo(() => { + const currentStepStart = section.blocks.findLastIndex(block => + defined(block.message.metadata?.step) + ); + return currentStepStart === -1 + ? section.blocks + : section.blocks.slice(currentStepStart); + }, [section.blocks]); + const artifact = useMemo(() => { const sectionArtifact = getAutofixArtifactFromSection(section); return isCodeChangesArtifact(sectionArtifact) ? sectionArtifact : null; @@ -69,6 +200,9 @@ export function CodeChangesCard({autofix, section}: CodeChangesCardProps) { step: 'code_changes', }); + const prIterationEnabled = hasPrIterationFeature; + const hasPRs = Object.keys(autofix.runState?.repo_pr_states ?? {}).length > 0; + const patchesByRepo = useMemo(() => collectPatches(artifact ?? []), [artifact]); const explanation = useMemo(() => getFinalExplanation(section), [section]); @@ -95,10 +229,22 @@ export function CodeChangesCard({autofix, section}: CodeChangesCardProps) { return t('%s files changed in %s repos', filesChanged.size, reposChanged); }, [patchesByRepo]); + const isProcessing = section.status === 'processing'; + return ( } - title={t('Code Changes')} + title={ + latestIterationIndex === null ? ( + t('Code Changes') + ) : ( + + {t('Code Changes')} + {/* `iteration_index` is zero-based; display a one-based version number. */} + {t('v%s - Latest', latestIterationIndex + 1)} + + ) + } onCopy={ markdown ? () => copy(markdown, {successMessage: t('Copied to clipboard.')}) @@ -107,21 +253,42 @@ export function CodeChangesCard({autofix, section}: CodeChangesCardProps) { allowReset onReset={canReset ? () => setShouldShowReset(true) : undefined} > - {section.status === 'processing' ? ( + {feedback.length > 0 && ( + + {t('Feedback')} + {feedback.map(item => ( + + ))} + + )} + {isProcessing ? ( ) : artifact && patchesByRepo.size ? ( - {shouldShowReset && ( - setShouldShowReset(false)} - onReset={handleReset} - placeholder={t('Give seer additional context to improve this code change.')} - prompt={t('How can this code change be improved?')} - /> - )} + {shouldShowReset && + (hasPRs && prIterationEnabled ? ( + setShouldShowReset(false)} + /> + ) : ( + setShouldShowReset(false)} + onReset={handleReset} + placeholder={t( + 'Give seer additional context to improve this code change.' + )} + prompt={t('How can this code change be improved?')} + /> + ))} {summary} @@ -144,21 +311,35 @@ export function CodeChangesCard({autofix, section}: CodeChangesCardProps) { ))} ) : explanation ? ( - + + + + {t("Seer proposed a fix but couldn't apply it automatically")} + + + + {shouldShowReset ? ( - setShouldShowReset(false)} - onReset={handleReset} - placeholder={t( - 'Add context that could unblock the change, e.g. the repo or files to edit.' - )} - prompt={t('What additional context should Seer use?')} - /> - ) : null} - {t("Seer proposed a fix but couldn't apply it automatically")} - - {shouldShowReset ? null : ( -
+ hasPRs && prIterationEnabled ? ( + setShouldShowReset(false)} + /> + ) : ( + setShouldShowReset(false)} + onReset={handleReset} + placeholder={t( + 'Add context that could unblock the change, e.g. the repo or files to edit.' + )} + prompt={t('What additional context should Seer use?')} + /> + ) + ) : ( + -
+ )}
) : ( @@ -191,3 +372,48 @@ export function CodeChangesCard({autofix, section}: CodeChangesCardProps) {
); } + +function FeedbackAttribution({item}: {item: IterationFeedback}) { + switch (item.sourceType) { + case 'github-pr-comment': + return ( + + + + {item.githubUsername && + (item.commentUrl ? ( + + {item.githubUsername} + + ) : ( + + {item.githubUsername} + + ))} + + + ); + case 'user-ui': + return item.user ? : null; + default: + return null; + } +} + +function FeedbackItem({item}: {item: IterationFeedback}) { + return ( + + + + {t('"%s"', item.text)} + + {item.timestamp && ( + + + + + + )} + + ); +} diff --git a/static/app/components/events/autofix/v3/content.tsx b/static/app/components/events/autofix/v3/content.tsx index 37c5412400e19a..7cc0729dd500d1 100644 --- a/static/app/components/events/autofix/v3/content.tsx +++ b/static/app/components/events/autofix/v3/content.tsx @@ -8,6 +8,7 @@ import { getOrderedAutofixSections, isCodeChangesSection, isCodingAgentsSection, + isLastStepPrIteration, isPullRequestsSection, isRootCauseSection, isSolutionSection, @@ -58,7 +59,8 @@ export function SeerDrawerContent({aiConfig, autofix, group}: SeerDrawerContentP return ( - {autofix.runState?.status === 'completed' && ( + {(autofix.runState?.status === 'completed' || + isLastStepPrIteration(autofix.runState)) && ( )} {autofix.codingAgentErrors.map(({id, message}) => ( @@ -110,7 +112,14 @@ function SeerDrawerArtifacts({autofix, groupId, sections}: SeerDrawerArtifactsPr } if (isCodeChangesSection(section)) { - return ; + return ( + + ); } if (isPullRequestsSection(section)) { diff --git a/static/app/components/events/autofix/v3/nextStep.spec.tsx b/static/app/components/events/autofix/v3/nextStep.spec.tsx index 0a244ab3ebac15..806360ff354583 100644 --- a/static/app/components/events/autofix/v3/nextStep.spec.tsx +++ b/static/app/components/events/autofix/v3/nextStep.spec.tsx @@ -1,4 +1,5 @@ import {GroupFixture} from 'sentry-fixture/group'; +import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; @@ -7,10 +8,13 @@ import type { AutofixSection, useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {trackAnalytics} from 'sentry/utils/analytics'; import type {ExplorerFilePatch} from 'sentry/views/seerExplorer/types'; import {SeerDrawerNextStep} from './nextStep'; +jest.mock('sentry/utils/analytics'); + function makeAutofix( overrides: Partial> = {} ): ReturnType { @@ -113,6 +117,18 @@ describe('SeerDrawerNextStep', () => { expect(container).toBeEmptyDOMElement(); }); + it('returns null while polling', () => { + const autofix = makeAutofix({isPolling: true}); + const {container} = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + describe('RootCauseNextStep', () => { beforeEach(() => { MockApiClient.addMockResponse({ @@ -495,4 +511,114 @@ describe('SeerDrawerNextStep', () => { ).not.toBeInTheDocument(); }); }); + + describe('PullRequestNextStep', () => { + const prIterationOrganization = OrganizationFixture({ + features: ['autofix-pr-iteration'], + }); + + function makePrIterationAutofix( + overrides: Partial> = {} + ) { + return makeAutofix({ + runState: {run_id: 1, blocks: []} as any, + ...overrides, + }); + } + + beforeEach(() => { + jest.mocked(trackAnalytics).mockClear(); + }); + + it('returns null when the run is not valid for PR iteration', () => { + const autofix = makeAutofix({ + runState: {run_id: 1, blocks: []} as any, + }); + const {container} = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the feedback prompt, textarea, and submit button', () => { + render( + , + {organization: prIterationOrganization} + ); + expect( + screen.getByText('Anything else you want to see on your PR?') + ).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Submit'})).toBeDisabled(); + }); + + it('submits feedback via startStep and tracks analytics', async () => { + const autofix = makePrIterationAutofix(); + render( + , + {organization: prIterationOrganization} + ); + + await userEvent.type(screen.getByRole('textbox'), 'Add a test for this'); + await userEvent.click(screen.getByRole('button', {name: 'Submit'})); + + expect(autofix.startStep).toHaveBeenCalledWith('pr_iteration', { + runId: 1, + userContext: 'Add a test for this', + }); + expect(trackAnalytics).toHaveBeenCalledWith( + 'autofix.pr_iteration.feedback', + expect.objectContaining({group_id: '123', mode: 'explorer'}) + ); + }); + + it('submits on Enter but not on Shift+Enter', async () => { + const autofix = makePrIterationAutofix(); + render( + , + {organization: prIterationOrganization} + ); + + const textbox = screen.getByRole('textbox'); + await userEvent.type(textbox, 'first line{Shift>}{Enter}{/Shift}'); + expect(autofix.startStep).not.toHaveBeenCalled(); + + await userEvent.type(textbox, '{Enter}'); + expect(autofix.startStep).toHaveBeenCalledWith( + 'pr_iteration', + expect.objectContaining({userContext: expect.stringContaining('first line')}) + ); + }); + + it('does not submit when feedback is empty', async () => { + const autofix = makePrIterationAutofix(); + render( + , + {organization: prIterationOrganization} + ); + + await userEvent.type(screen.getByRole('textbox'), '{Enter}'); + expect(autofix.startStep).not.toHaveBeenCalled(); + }); + }); }); diff --git a/static/app/components/events/autofix/v3/nextStep.tsx b/static/app/components/events/autofix/v3/nextStep.tsx index 45c9664819bd47..8b80a5b3f7fb15 100644 --- a/static/app/components/events/autofix/v3/nextStep.tsx +++ b/static/app/components/events/autofix/v3/nextStep.tsx @@ -16,11 +16,14 @@ import { import { getAutofixArtifactFromSection, isCodeChangesSection, + isPullRequestsSection, isRootCauseSection, + isRunValidForPrIteration, isSolutionSection, type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {PrIterationFeedbackForm} from 'sentry/components/events/autofix/v3/prIterationFeedbackForm'; import {IconAdd} from 'sentry/icons/iconAdd'; import {IconChevron} from 'sentry/icons/iconChevron'; import {t} from 'sentry/locale'; @@ -45,6 +48,11 @@ export function SeerDrawerNextStep({sections, group, autofix}: SeerDrawerNextSte return null; } + // needed to hide PR iteration after clicking "submit feedback" button + if (autofix.isPolling) { + return null; + } + if (isRootCauseSection(section)) { return ( + ); + } + return null; } +function PullRequestNextStep({autofix, group, runId, referrer}: NextStepProps) { + const organization = useOrganization(); + + if (!isRunValidForPrIteration(organization)) { + return null; + } + + return ( + + ); +} + interface NextStepProps { autofix: ReturnType; group: Group; diff --git a/static/app/components/events/autofix/v3/prIterationFeedbackForm.tsx b/static/app/components/events/autofix/v3/prIterationFeedbackForm.tsx new file mode 100644 index 00000000000000..befcd869834fd6 --- /dev/null +++ b/static/app/components/events/autofix/v3/prIterationFeedbackForm.tsx @@ -0,0 +1,113 @@ +import {useRef, useState} from 'react'; + +import {Button} from '@sentry/scraps/button'; +import {InputGroup} from '@sentry/scraps/input'; +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {type useExplorerAutofix} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {IconArrow} from 'sentry/icons/iconArrow'; +import {IconClose} from 'sentry/icons/iconClose'; +import {IconReturn} from 'sentry/icons/iconReturn'; +import {t} from 'sentry/locale'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +interface PrIterationFeedbackFormProps { + autofix: ReturnType; + groupId: string; + onClose?: () => void; + referrer?: string; + runId?: number; +} + +export function PrIterationFeedbackForm({ + autofix, + groupId, + runId, + referrer, + onClose, +}: PrIterationFeedbackFormProps) { + const organization = useOrganization(); + const {isPolling, startStep} = autofix; + const [feedback, setFeedback] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const submitButtonRef = useRef(null); + + const prompt = t('Anything else you want to see on your PR?'); + + const handleSubmit = async () => { + if (!feedback.trim()) { + return; + } + // Show the loader immediately on click. We don't reuse `isPolling` here + // because submitting kicks off a run that flips it, which can hide the + // surrounding UI before a polling-driven busy state could render. The + // component unmounts and remounts fresh (resetting this flag) once the run + // completes. + setIsSubmitting(true); + try { + await startStep('pr_iteration', {runId, userContext: feedback}); + } catch { + setIsSubmitting(false); + addErrorMessage(t('Failed to submit feedback. Please try again.')); + return; + } + trackAnalytics('autofix.pr_iteration.feedback', { + organization, + group_id: groupId, + mode: 'explorer', + referrer, + }); + // Dismiss the reset UI for callers that render this inline (e.g. the code + // changes card) so it doesn't reappear after the run completes. Callers + // without an onClose (e.g. the next-step view) keep relying on isSubmitting. + onClose?.(); + }; + + return ( + + {prompt} + + setFeedback(event.target.value)} + onKeyDown={event => { + if (event.nativeEvent.isComposing) { + return; + } + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + // Simulate a real click on the submit button (matching the Ask Seer + // hotkey behavior) so the press goes through the button itself. + submitButtonRef.current?.click(); + } + }} + /> + + + + + + {onClose && ( + + + + ); +} diff --git a/static/app/components/events/autofix/v3/pullRequestsCard.tsx b/static/app/components/events/autofix/v3/pullRequestsCard.tsx index 42d40163fc4ac7..092dde2205c68f 100644 --- a/static/app/components/events/autofix/v3/pullRequestsCard.tsx +++ b/static/app/components/events/autofix/v3/pullRequestsCard.tsx @@ -54,7 +54,9 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { if (pullRequest.pr_creation_status === 'creating') { return ( ); } diff --git a/static/app/components/events/autofix/v3/useResetAutofixStep.spec.tsx b/static/app/components/events/autofix/v3/useResetAutofixStep.spec.tsx index e76d56e157aa1e..24ff69862efb05 100644 --- a/static/app/components/events/autofix/v3/useResetAutofixStep.spec.tsx +++ b/static/app/components/events/autofix/v3/useResetAutofixStep.spec.tsx @@ -1,4 +1,6 @@ -import {act, renderHook} from 'sentry-test/reactTestingLibrary'; +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; import type { AutofixSection, @@ -48,7 +50,7 @@ describe('useResetAutofixStep', () => { }, }); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({autofix, section: makeSection(), step: 'root_cause'}) ); @@ -65,7 +67,7 @@ describe('useResetAutofixStep', () => { }, }); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({autofix, section: makeSection(), step: 'root_cause'}) ); @@ -96,13 +98,50 @@ describe('useResetAutofixStep', () => { }, }); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({autofix, section: makeSection(), step: 'root_cause'}) ); expect(result.current.canReset).toBe(false); }); + it('allows code_changes reset after a PR only with the autofix-pr-iteration feature', () => { + const autofix = makeAutofix({ + runState: { + run_id: 1, + status: 'completed', + blocks: [], + updated_at: '2024-01-01T00:00:00Z', + repo_pr_states: { + 'repo-1': { + repo_name: 'repo-1', + branch_name: 'fix/branch', + commit_sha: 'abc123', + pr_creation_error: null, + pr_creation_status: 'completed', + pr_id: 1, + pr_number: 42, + pr_url: 'https://github.com/org/repo/pull/42', + title: 'Fix bug', + }, + }, + coding_agents: {}, + }, + }); + + const withoutFeature = renderHookWithProviders(() => + useResetAutofixStep({autofix, section: makeSection(), step: 'code_changes'}) + ); + expect(withoutFeature.result.current.canReset).toBe(false); + + const withFeature = renderHookWithProviders( + () => + useResetAutofixStep({autofix, section: makeSection(), step: 'code_changes'}), + {organization: OrganizationFixture({features: ['autofix-pr-iteration']})} + ); + expect(withFeature.result.current.canReset).toBe(true); + }); + it('returns false when coding agents have been started', () => { const autofix = makeAutofix({ runState: { @@ -123,7 +162,7 @@ describe('useResetAutofixStep', () => { }, }); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({autofix, section: makeSection(), step: 'root_cause'}) ); @@ -142,7 +181,7 @@ describe('useResetAutofixStep', () => { }, }); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({autofix, section: makeSection(), step: 'root_cause'}) ); @@ -168,7 +207,7 @@ describe('useResetAutofixStep', () => { }); const section = makeSection({index: 3}); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({autofix, section, step: 'solution'}) ); @@ -191,7 +230,7 @@ describe('useResetAutofixStep', () => { }, }); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({ autofix, section: makeSection({index: 1}), @@ -213,7 +252,7 @@ describe('useResetAutofixStep', () => { it('defaults shouldShowReset to false and allows toggling', () => { const autofix = makeAutofix(); - const {result} = renderHook(() => + const {result} = renderHookWithProviders(() => useResetAutofixStep({autofix, section: makeSection(), step: 'root_cause'}) ); diff --git a/static/app/components/events/autofix/v3/useResetAutofixStep.tsx b/static/app/components/events/autofix/v3/useResetAutofixStep.tsx index 426f01202adb80..97c8fc6568339a 100644 --- a/static/app/components/events/autofix/v3/useResetAutofixStep.tsx +++ b/static/app/components/events/autofix/v3/useResetAutofixStep.tsx @@ -1,10 +1,14 @@ import {useMemo, useState} from 'react'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import { + isRunValidForPrIteration, type AutofixExplorerStep, type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {t} from 'sentry/locale'; +import {useOrganization} from 'sentry/utils/useOrganization'; interface UseResetAutofixStepOptions { autofix: ReturnType; @@ -19,12 +23,22 @@ export function useResetAutofixStep({ }: UseResetAutofixStepOptions) { const [shouldShowReset, setShouldShowReset] = useState(false); + const organization = useOrganization(); const {runState, startStep} = autofix; const runId = runState?.run_id; + const allowResetAfterPRs = isRunValidForPrIteration(organization); const handleReset = useMemo(() => { - return (userContext?: string) => { - startStep(step, {runId, userContext, insertIndex: section.index}); + return async (userContext?: string) => { + // Dismiss the reset UI before kicking off the run so it doesn't reappear + // once the run completes (during processing the loading view takes over). + setShouldShowReset(false); + try { + await startStep(step, {runId, userContext, insertIndex: section.index}); + } catch { + setShouldShowReset(true); + addErrorMessage(t('Failed to reset. Please try again.')); + } }; }, [startStep, step, runId, section.index]); @@ -34,8 +48,12 @@ export function useResetAutofixStep({ !shouldShowReset && // can only reset if run state is not processing autofix.runState?.status !== 'processing' && - // can only reset if PRs states are empty (i.e. no PR have been created) - Object.values(autofix.runState?.repo_pr_states ?? {}).length === 0 && + // can only reset if PRs states are empty (i.e. no PR have been created), + // except on code_changes card where PR iteration is supported + (step === 'code_changes' + ? allowResetAfterPRs || + Object.values(autofix.runState?.repo_pr_states ?? {}).length === 0 + : Object.values(autofix.runState?.repo_pr_states ?? {}).length === 0) && // can only reset if coding agents are empty (i.e. no coding agents have been started) Object.values(autofix.runState?.coding_agents ?? {}).length === 0, shouldShowReset, diff --git a/static/app/utils/analytics/seerAnalyticsEvents.tsx b/static/app/utils/analytics/seerAnalyticsEvents.tsx index ff13cb24e54869..7563d68da03785 100644 --- a/static/app/utils/analytics/seerAnalyticsEvents.tsx +++ b/static/app/utils/analytics/seerAnalyticsEvents.tsx @@ -67,6 +67,12 @@ export type SeerAnalyticsEventsParameters = { organization: Organization; tool_name: string; }; + 'autofix.pr_iteration.feedback': { + group_id: string; + organization: Organization; + mode?: 'explorer'; + referrer?: string; + }; 'autofix.root_cause.find_solution': { group_id: string; organization: Organization; @@ -175,6 +181,7 @@ export const seerAnalyticsEventsMap: Record