Skip to content

Commit 91aba65

Browse files
joseph-sentryclaude
andcommitted
feat(autofix): Handle PR iteration completion and introspection
Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
1 parent 17c6766 commit 91aba65

3 files changed

Lines changed: 247 additions & 28 deletions

File tree

src/sentry/seer/autofix/introspection.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from sentry.models.organization import Organization
99
from sentry.seer.agent.client_models import SeerRunState
1010
from sentry.seer.autofix.artifact_schemas import RootCauseArtifact, SolutionArtifact
11-
from sentry.seer.autofix.autofix_agent import AutofixStep
11+
from sentry.seer.autofix.autofix_agent import AutofixStep, get_latest_iteration_index
1212
from sentry.seer.models.seer_api_models import SeerApiError
1313
from sentry.seer.signed_seer_api import LlmGenerateRequest, make_llm_generate_request
1414
from sentry.utils import json, metrics
@@ -442,3 +442,86 @@ def introspect_code_changes(
442442
)
443443

444444
return None
445+
446+
447+
def _iteration_introspection_prompt(
448+
*,
449+
short_id: str,
450+
title: str,
451+
culprit: str,
452+
diffs_by_repo: dict[str, str],
453+
) -> str:
454+
return dedent(f"""\
455+
You are evaluating whether pull request iteration changes are ready for issue {short_id}: "{title}" (culprit: {culprit}).
456+
457+
## Revised Code Changes to Evaluate
458+
{_format_code_changes_section(diffs_by_repo)}
459+
## Your Task
460+
461+
Evaluate whether the revised changes are suitable to update the existing pull request.
462+
463+
Choose one action:
464+
465+
- **continue**: The revised changes are focused, coherent, and suitable to add to the existing pull request.
466+
467+
- **needs_more_context**: The revised changes are incomplete or there is not enough evidence to tell whether they address the requested iteration.
468+
469+
- **redo**: The revised changes introduce obvious bugs, contradict the prior fix, or make unrelated modifications.
470+
471+
- **not_actionable**: The pull request cannot be iterated through code changes. For example: no revised code changes were produced.
472+
473+
Include a brief reason (1-2 sentences) explaining your decision.\
474+
""")
475+
476+
477+
def introspect_iteration(
478+
organization: Organization,
479+
run_id: int,
480+
state: SeerRunState,
481+
group: Group,
482+
) -> IntrospectionDecision | None:
483+
iteration_index = get_latest_iteration_index(state)
484+
try:
485+
diffs_by_repo = state.get_diffs_by_repo()
486+
if not diffs_by_repo:
487+
logger.warning(
488+
"autofix.introspection.no_artifact",
489+
extra={
490+
"run_id": run_id,
491+
"organization_id": organization.id,
492+
"step": "pr_iteration",
493+
"type": "code_changes",
494+
"iteration_index": iteration_index,
495+
},
496+
)
497+
return None
498+
499+
diffs_by_repo_str = {
500+
repo: "\n".join(fp.diff for fp in patches if fp.diff)
501+
for repo, patches in diffs_by_repo.items()
502+
}
503+
504+
prompt = _iteration_introspection_prompt(
505+
short_id=group.qualified_short_id or str(group.id),
506+
title=group.title or "",
507+
culprit=group.culprit or "",
508+
diffs_by_repo=diffs_by_repo_str,
509+
)
510+
511+
return _run_introspection(
512+
run_id,
513+
AutofixStep.PR_ITERATION,
514+
prompt,
515+
)
516+
except Exception:
517+
logger.exception(
518+
"autofix.introspection.failed",
519+
extra={
520+
"run_id": run_id,
521+
"organization_id": organization.id,
522+
"step": "pr_iteration",
523+
"iteration_index": iteration_index,
524+
},
525+
)
526+
527+
return None

src/sentry/seer/autofix/on_completion_hook.py

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from sentry.seer.autofix.autofix_agent import (
2121
STEP_CONFIGS,
2222
AutofixStep,
23+
get_latest_iteration_index,
2324
trigger_autofix_agent,
2425
trigger_coding_agent_handoff,
2526
trigger_push_changes,
@@ -29,6 +30,7 @@
2930
from sentry.seer.autofix.introspection import (
3031
IntrospectionDecision,
3132
introspect_code_changes,
33+
introspect_iteration,
3234
introspect_root_cause,
3335
introspect_solution,
3436
)
@@ -167,18 +169,7 @@ def _send_step_webhook(
167169
# handled but the expectation is that we only create PRs once
168170
# per seer run.
169171
webhook_action_type = SeerActionType.PR_CREATED
170-
webhook_payload["pull_requests"] = [
171-
{
172-
"provider": "unknown", # TODO: we don't have the repo object readily accessible here
173-
"repo_name": pull_request.repo_name,
174-
"pull_request": {
175-
"pr_id": pull_request.pr_id,
176-
"pr_number": pull_request.pr_number,
177-
"pr_url": pull_request.pr_url,
178-
},
179-
}
180-
for pull_request in state.repo_pr_states.values()
181-
]
172+
webhook_payload["pull_requests"] = cls._format_pull_requests_payload(state)
182173
is_pr_created = True
183174
analytics.record(
184175
AiAutofixPrCreatedCompletedEvent(
@@ -190,20 +181,15 @@ def _send_step_webhook(
190181
)
191182
else:
192183
webhook_action_type = SeerActionType.CODING_COMPLETED
193-
diffs_by_repo = state.get_diffs_by_repo()
194-
webhook_payload["code_changes"] = {
195-
repo: [
196-
{
197-
"diff": p.diff,
198-
"path": p.patch.path,
199-
"type": p.patch.type,
200-
"added": p.patch.added,
201-
"removed": p.patch.removed,
202-
}
203-
for p in patches
204-
]
205-
for repo, patches in diffs_by_repo.items()
206-
}
184+
webhook_payload["code_changes"] = cls._format_code_changes_payload(state)
185+
elif current_step == AutofixStep.PR_ITERATION:
186+
# PR iteration only runs against an existing PR, so there must be pr states.
187+
assert state.repo_pr_states, "PR iteration completed without any repo PR states"
188+
webhook_action_type = SeerActionType.ITERATION_COMPLETED
189+
iteration_index = get_latest_iteration_index(state)
190+
webhook_payload["pull_requests"] = cls._format_pull_requests_payload(state)
191+
webhook_payload["code_changes"] = cls._format_code_changes_payload(state)
192+
webhook_payload["iteration_index"] = iteration_index
207193

208194
if not webhook_action_type:
209195
return
@@ -264,6 +250,38 @@ def _send_step_webhook(
264250
)
265251
)
266252

253+
@classmethod
254+
def _format_code_changes_payload(cls, state: SeerRunState) -> dict:
255+
diffs_by_repo = state.get_diffs_by_repo()
256+
return {
257+
repo: [
258+
{
259+
"diff": p.diff,
260+
"path": p.patch.path,
261+
"type": p.patch.type,
262+
"added": p.patch.added,
263+
"removed": p.patch.removed,
264+
}
265+
for p in patches
266+
]
267+
for repo, patches in diffs_by_repo.items()
268+
}
269+
270+
@classmethod
271+
def _format_pull_requests_payload(cls, state: SeerRunState) -> list[dict]:
272+
return [
273+
{
274+
"provider": "unknown",
275+
"repo_name": pull_request.repo_name,
276+
"pull_request": {
277+
"pr_id": pull_request.pr_id,
278+
"pr_number": pull_request.pr_number,
279+
"pr_url": pull_request.pr_url,
280+
},
281+
}
282+
for pull_request in state.repo_pr_states.values()
283+
]
284+
267285
@classmethod
268286
def _get_current_step(
269287
cls, state: SeerRunState
@@ -350,6 +368,7 @@ def _maybe_continue_pipeline(
350368
group,
351369
)
352370
if decision is not None:
371+
iteration_index = get_latest_iteration_index(state)
353372
analytics.record(
354373
AiAutofixIntrospectionEvent(
355374
organization_id=organization.id,
@@ -359,6 +378,7 @@ def _maybe_continue_pipeline(
359378
step=current_step.value,
360379
action=decision.action.value,
361380
reached_stopping_point=reached_stopping_point,
381+
iteration_index=iteration_index,
362382
)
363383
)
364384
logger.info(
@@ -452,7 +472,8 @@ def run_introspection(
452472
return introspect_solution(organization, run_id, state, group)
453473
elif step == AutofixStep.CODE_CHANGES:
454474
return introspect_code_changes(organization, run_id, state, group)
455-
return None
475+
elif step == AutofixStep.PR_ITERATION:
476+
return introspect_iteration(organization, run_id, state, group)
456477

457478
@classmethod
458479
def _push_changes(cls, group: Group, run_id: int, state: SeerRunState) -> None:

tests/sentry/seer/autofix/test_autofix_on_completion_hook.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,31 @@ def code_changes_memory_block(referrer: str | None = None) -> MemoryBlock:
9999
)
100100

101101

102+
def pr_iteration_memory_block(referrer: str | None = None, iteration_index: int = 1) -> MemoryBlock:
103+
metadata: dict[str, str] = {
104+
"step": "pr_iteration",
105+
"iteration_index": str(iteration_index),
106+
}
107+
if referrer is not None:
108+
metadata["referrer"] = referrer
109+
return MemoryBlock(
110+
id="block-pr-iteration",
111+
message=Message(
112+
role="assistant",
113+
content="message pr iteration",
114+
metadata=metadata,
115+
),
116+
timestamp="2026-02-10T00:00:00Z",
117+
merged_file_patches=[
118+
AgentFilePatch(
119+
repo_name="test-repo",
120+
diff="diff --git a/test.py b/test.py",
121+
patch=FilePatch(path="test.py", type="M", added=2, removed=1),
122+
)
123+
],
124+
)
125+
126+
102127
class TestAutofixOnCompletionHookHelpers(TestCase):
103128
"""Tests for helper methods in AutofixOnCompletionHook."""
104129

@@ -179,6 +204,27 @@ def test_get_next_step_code_changes_is_last(self) -> None:
179204
result = AutofixOnCompletionHook._get_next_step(AutofixStep.CODE_CHANGES)
180205
assert result is None
181206

207+
def test_get_next_step_pr_iteration_is_terminal(self) -> None:
208+
result = AutofixOnCompletionHook._get_next_step(AutofixStep.PR_ITERATION)
209+
assert result is None
210+
211+
@patch("sentry.seer.autofix.on_completion_hook.introspect_iteration")
212+
def test_run_introspection_pr_iteration(self, mock_introspect_iteration) -> None:
213+
organization = self.create_organization()
214+
project = self.create_project(organization=organization)
215+
group = self.create_group(project=project)
216+
state = run_state(blocks=[pr_iteration_memory_block()])
217+
218+
AutofixOnCompletionHook.run_introspection(
219+
organization,
220+
123,
221+
state,
222+
AutofixStep.PR_ITERATION,
223+
group,
224+
)
225+
226+
mock_introspect_iteration.assert_called_once_with(organization, 123, state, group)
227+
182228

183229
class TestAutofixOnCompletionHookPipeline(TestCase):
184230
"""Tests for pipeline continuation logic."""
@@ -406,6 +452,75 @@ def test_send_step_webhook_coding(self, mock_broadcast):
406452
assert call_kwargs["payload"]["code_changes"]["test-repo"][0]["added"] == 5
407453
assert call_kwargs["payload"]["code_changes"]["test-repo"][0]["removed"] == 2
408454

455+
@patch("sentry.seer.autofix.on_completion_hook.analytics.record")
456+
@patch("sentry.seer.autofix.on_completion_hook.process_autofix_updates.apply_async")
457+
@patch("sentry.seer.autofix.on_completion_hook.SeerAutofixOperator.has_access")
458+
@patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay")
459+
def test_send_step_webhook_pr_iteration(
460+
self, mock_broadcast, mock_has_access, mock_process_autofix_updates, mock_analytics
461+
):
462+
mock_has_access.return_value = True
463+
state = run_state(
464+
blocks=[
465+
root_cause_memory_block(),
466+
solution_memory_block(),
467+
code_changes_memory_block(),
468+
pr_iteration_memory_block(referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT.value),
469+
]
470+
)
471+
state.repo_pr_states = {
472+
"test-repo": RepoPRState(
473+
repo_name="test-repo",
474+
pr_id=77,
475+
pr_number=7,
476+
pr_url="https://example.com/pull/7",
477+
pr_creation_status="completed",
478+
)
479+
}
480+
481+
AutofixOnCompletionHook._send_step_webhook(self.organization, 123, state, self.group)
482+
483+
mock_broadcast.assert_called_once()
484+
call_kwargs = mock_broadcast.call_args.kwargs
485+
assert call_kwargs["event_name"] == SeerActionType.ITERATION_COMPLETED.value
486+
assert call_kwargs["payload"]["code_changes"]["test-repo"][0]["path"] == "test.py"
487+
assert call_kwargs["payload"]["pull_requests"][0]["pull_request"]["pr_number"] == 7
488+
mock_process_autofix_updates.assert_called_once()
489+
assert (
490+
mock_analytics.call_args.args[0].referrer
491+
== AutofixReferrer.GROUP_AUTOFIX_ENDPOINT.value
492+
)
493+
494+
@patch("sentry.seer.autofix.on_completion_hook.analytics.record")
495+
@patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay")
496+
def test_send_step_webhook_pr_iteration_does_not_emit_pr_created(
497+
self, mock_broadcast, mock_analytics
498+
):
499+
state = run_state(
500+
blocks=[
501+
code_changes_memory_block(),
502+
pr_iteration_memory_block(),
503+
]
504+
)
505+
state.repo_pr_states = {
506+
"test-repo": RepoPRState(
507+
repo_name="test-repo",
508+
pr_id=77,
509+
pr_number=7,
510+
pr_url="https://example.com/pull/7",
511+
pr_creation_status="completed",
512+
)
513+
}
514+
515+
AutofixOnCompletionHook._send_step_webhook(self.organization, 123, state, self.group)
516+
517+
assert (
518+
mock_broadcast.call_args.kwargs["event_name"]
519+
== SeerActionType.ITERATION_COMPLETED.value
520+
)
521+
event_names = [call.args[0].type for call in mock_analytics.call_args_list]
522+
assert "ai.autofix.pr_created.completed" not in event_names
523+
409524
@patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay")
410525
def test_send_step_webhook_no_artifacts_no_webhook(self, mock_broadcast):
411526
"""Does not send webhook when no artifacts or file patches exist."""

0 commit comments

Comments
 (0)