Skip to content

Commit 91ddc73

Browse files
joseph-sentrygetsantry[bot]alexsohn1126
authored
feat(autofix): PR iteration frontend changes (#118049)
there's multiple changes happening in this PR: - update the logic to display code changes from PR iteration blocks in the autofix explorer conversation in the code changes card - show feedback from the PR iteration blocks in the code changes card - after a PR is created show the Pull request next step section (textarea + button) - show the pull request next step section within the code changes card when a user clicks the "reset" button on the card - clicking the submit button after populating feedback in the PR iteration section will send the request to the Sentry backend which will forward that to Seer to run the Autofix Fixes https://linear.app/getsentry/issue/CW-1397 <img width="553" height="252" alt="image" src="https://github.com/user-attachments/assets/8fc8c0d7-27cd-41a9-827b-6ad058c6cde1" /> <img width="550" height="165" alt="image" src="https://github.com/user-attachments/assets/cce853b9-78bc-4990-b4d2-ec523721b8ef" /> <img width="557" height="304" alt="image" src="https://github.com/user-attachments/assets/ecaefa55-c899-4d7a-95e1-26938e4a6781" /> <img width="557" height="195" alt="image" src="https://github.com/user-attachments/assets/3fd522b3-59c8-460e-ba14-2e76c395cb52" /> <img width="554" height="116" alt="image" src="https://github.com/user-attachments/assets/523e99f1-43fe-4b41-8374-abcd7f76f427" /> --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> Co-authored-by: Alex Sohn <alexsohn1126@gmail.com>
1 parent a07e96c commit 91ddc73

12 files changed

Lines changed: 1199 additions & 120 deletions

static/app/components/events/autofix/useExplorerAutofix.spec.tsx

Lines changed: 262 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
13
import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';
24

35
import {addErrorMessage} from 'sentry/actionCreators/indicator';
46
import {DiffFileType, DiffLineType} from 'sentry/components/events/autofix/types';
57
import {
68
collectPatches,
9+
getOrderedAutofixSections,
710
isCodeChangesArtifact,
811
isCodingAgentsArtifact,
12+
isLastStepPrIteration,
13+
isPrIterationBlock,
914
isPullRequestsArtifact,
1015
isRootCauseArtifact,
16+
isRunValidForPrIteration,
1117
isSolutionArtifact,
1218
useExplorerAutofix,
19+
type ExplorerAutofixState,
1320
type RootCauseArtifact,
1421
type SolutionArtifact,
1522
} from 'sentry/components/events/autofix/useExplorerAutofix';
16-
import type {Artifact, ExplorerFilePatch} from 'sentry/views/seerExplorer/types';
23+
import type {Artifact, Block, ExplorerFilePatch} from 'sentry/views/seerExplorer/types';
1724

1825
jest.mock('sentry/actionCreators/indicator');
1926

@@ -391,6 +398,260 @@ describe('collectPatches', () => {
391398
});
392399
});
393400

401+
describe('getOrderedAutofixSections', () => {
402+
let blockId = 0;
403+
404+
function makeBlock(
405+
overrides: Omit<Partial<Block>, 'message'> & {message?: Partial<Block['message']>}
406+
) {
407+
const {message, ...rest} = overrides;
408+
return {
409+
id: `block-${blockId++}`,
410+
timestamp: '2026-01-01T00:00:00Z',
411+
message: {
412+
content: 'hello',
413+
role: 'assistant',
414+
...message,
415+
},
416+
...rest,
417+
} as Block;
418+
}
419+
420+
function makePatch(repoName: string, path: string, diff = 'diff'): ExplorerFilePatch {
421+
return {
422+
repo_name: repoName,
423+
diff,
424+
patch: {
425+
added: 1,
426+
removed: 0,
427+
path,
428+
source_file: path,
429+
target_file: path,
430+
type: DiffFileType.MODIFIED,
431+
hunks: [],
432+
},
433+
};
434+
}
435+
436+
function makeState(blocks: Block[]): ExplorerAutofixState {
437+
return {
438+
run_id: 1,
439+
status: 'completed',
440+
updated_at: '2026-01-01T00:00:00Z',
441+
blocks,
442+
};
443+
}
444+
445+
it('returns an empty array for null state or no blocks', () => {
446+
expect(getOrderedAutofixSections(null)).toEqual([]);
447+
expect(getOrderedAutofixSections(makeState([]))).toEqual([]);
448+
});
449+
450+
it('groups blocks into sections at each step marker', () => {
451+
const sections = getOrderedAutofixSections(
452+
makeState([
453+
makeBlock({message: {metadata: {step: 'root_cause'}}}),
454+
makeBlock({}),
455+
makeBlock({message: {metadata: {step: 'solution'}}}),
456+
])
457+
);
458+
459+
expect(sections.map(s => s.step)).toEqual(['root_cause', 'solution']);
460+
expect(sections[0]!.blocks).toHaveLength(2);
461+
expect(sections[1]!.blocks).toHaveLength(1);
462+
});
463+
464+
it('merges all code_changes blocks into a single section with the cumulative diff', () => {
465+
const sections = getOrderedAutofixSections(
466+
makeState([
467+
makeBlock({
468+
message: {metadata: {step: 'code_changes'}},
469+
merged_file_patches: [makePatch('org/repo', 'a.py', 'first diff')],
470+
}),
471+
makeBlock({
472+
message: {metadata: {step: 'code_changes'}},
473+
merged_file_patches: [makePatch('org/repo', 'b.py', 'second diff')],
474+
}),
475+
])
476+
);
477+
478+
// Consecutive code_changes blocks collapse into one section that carries the
479+
// cumulative patch set merged across all of its blocks.
480+
expect(sections).toHaveLength(1);
481+
expect(sections[0]!.step).toBe('code_changes');
482+
expect(sections[0]!.artifacts).toEqual([
483+
[
484+
makePatch('org/repo', 'a.py', 'first diff'),
485+
makePatch('org/repo', 'b.py', 'second diff'),
486+
],
487+
]);
488+
});
489+
490+
it('folds consecutive pr_iteration blocks into the single code_changes section', () => {
491+
const sections = getOrderedAutofixSections(
492+
makeState([
493+
makeBlock({
494+
message: {metadata: {step: 'pr_iteration', iteration_index: '1'}},
495+
merged_file_patches: [makePatch('org/repo', 'a.py')],
496+
}),
497+
makeBlock({
498+
message: {metadata: {step: 'pr_iteration', iteration_index: '2'}},
499+
merged_file_patches: [makePatch('org/repo', 'b.py')],
500+
}),
501+
])
502+
);
503+
504+
// pr_iteration work is folded into the one code_changes section; both
505+
// iteration blocks and their merged patches live there.
506+
expect(sections).toHaveLength(1);
507+
expect(sections[0]!.step).toBe('code_changes');
508+
expect(sections[0]!.blocks.map(b => b.message.metadata?.iteration_index)).toEqual([
509+
'1',
510+
'2',
511+
]);
512+
expect(sections[0]!.artifacts).toEqual([
513+
[makePatch('org/repo', 'a.py'), makePatch('org/repo', 'b.py')],
514+
]);
515+
});
516+
517+
it('merges patches for the same file within a section, last write wins', () => {
518+
const sections = getOrderedAutofixSections(
519+
makeState([
520+
makeBlock({
521+
message: {metadata: {step: 'code_changes'}},
522+
merged_file_patches: [makePatch('org/repo', 'a.py', 'old')],
523+
}),
524+
makeBlock({
525+
merged_file_patches: [makePatch('org/repo', 'a.py', 'new')],
526+
}),
527+
])
528+
);
529+
530+
expect(sections).toHaveLength(1);
531+
expect(sections[0]!.artifacts).toEqual([[makePatch('org/repo', 'a.py', 'new')]]);
532+
});
533+
534+
it('does not push an empty patch artifact for a code-change section with no patches', () => {
535+
const sections = getOrderedAutofixSections(
536+
makeState([makeBlock({message: {metadata: {step: 'pr_iteration'}}})])
537+
);
538+
539+
expect(sections).toHaveLength(1);
540+
expect(sections[0]!.artifacts).toEqual([]);
541+
});
542+
543+
it('appends a synthetic pull_request section from repo_pr_states', () => {
544+
const prState = {
545+
repo_name: 'org/repo',
546+
pr_number: 42,
547+
pr_url: 'https://github.com/org/repo/pull/42',
548+
branch_name: 'fix/issue',
549+
commit_sha: 'abc123',
550+
pr_creation_error: null,
551+
pr_creation_status: 'completed',
552+
pr_id: 1,
553+
title: 'Fix issue',
554+
} as const;
555+
556+
const sections = getOrderedAutofixSections({
557+
...makeState([makeBlock({message: {metadata: {step: 'code_changes'}}})]),
558+
repo_pr_states: {'org/repo': prState},
559+
});
560+
561+
expect(sections.map(s => s.step)).toEqual(['code_changes', 'pull_request']);
562+
const prSection = sections[sections.length - 1]!;
563+
expect(prSection.status).toBe('completed');
564+
expect(prSection.artifacts).toEqual([[prState]]);
565+
});
566+
567+
it('marks the synthetic pull_request section as processing while a PR is creating', () => {
568+
const sections = getOrderedAutofixSections({
569+
...makeState([makeBlock({message: {metadata: {step: 'code_changes'}}})]),
570+
repo_pr_states: {
571+
'org/repo': {
572+
repo_name: 'org/repo',
573+
pr_creation_status: 'creating',
574+
} as any,
575+
},
576+
});
577+
578+
expect(sections[sections.length - 1]!.status).toBe('processing');
579+
});
580+
});
581+
582+
describe('isPrIterationBlock', () => {
583+
function block(metadata?: Record<string, string>): Block {
584+
return {
585+
id: 'block-1',
586+
timestamp: '2026-01-01T00:00:00Z',
587+
message: {content: 'hello', role: 'assistant', metadata},
588+
} as Block;
589+
}
590+
591+
it('is true only for blocks whose step is pr_iteration', () => {
592+
expect(isPrIterationBlock(block({step: 'pr_iteration'}))).toBe(true);
593+
expect(isPrIterationBlock(block({step: 'code_changes'}))).toBe(false);
594+
expect(isPrIterationBlock(block())).toBe(false);
595+
});
596+
});
597+
598+
describe('isRunValidForPrIteration', () => {
599+
it('is true only when the autofix-pr-iteration feature is enabled', () => {
600+
expect(
601+
isRunValidForPrIteration(OrganizationFixture({features: ['autofix-pr-iteration']}))
602+
).toBe(true);
603+
expect(isRunValidForPrIteration(OrganizationFixture({features: []}))).toBe(false);
604+
});
605+
});
606+
607+
describe('isLastStepPrIteration', () => {
608+
let blockId = 0;
609+
function block(step?: string): Block {
610+
return {
611+
id: `block-${blockId++}`,
612+
timestamp: '2026-01-01T00:00:00Z',
613+
message: {
614+
content: 'hello',
615+
role: 'assistant',
616+
metadata: step ? {step} : undefined,
617+
},
618+
} as Block;
619+
}
620+
function state(blocks: Block[]): ExplorerAutofixState {
621+
return {
622+
run_id: 1,
623+
status: 'completed',
624+
updated_at: '2026-01-01T00:00:00Z',
625+
blocks,
626+
};
627+
}
628+
629+
it('is true when the last block carrying a step is pr_iteration', () => {
630+
expect(
631+
isLastStepPrIteration(state([block('code_changes'), block('pr_iteration')]))
632+
).toBe(true);
633+
});
634+
635+
it('ignores trailing step-less blocks when finding the last step', () => {
636+
expect(
637+
isLastStepPrIteration(
638+
state([block('pr_iteration'), block(undefined), block(undefined)])
639+
)
640+
).toBe(true);
641+
});
642+
643+
it('is false when the last step is not pr_iteration', () => {
644+
expect(
645+
isLastStepPrIteration(state([block('pr_iteration'), block('code_changes')]))
646+
).toBe(false);
647+
});
648+
649+
it('is false when there are no blocks with a step or no run state', () => {
650+
expect(isLastStepPrIteration(state([block(undefined)]))).toBe(false);
651+
expect(isLastStepPrIteration(null)).toBe(false);
652+
});
653+
});
654+
394655
describe('useExplorerAutofix - createPR', () => {
395656
const GROUP_ID = '123';
396657
const AUTOFIX_URL = `/organizations/org-slug/issues/${GROUP_ID}/autofix/`;

0 commit comments

Comments
 (0)