Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions src/sentry/analytics/events/pr_iteration_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ class AiAutofixPrIterationFeedbackBatchCompletedEvent(analytics.Event):
# Review bots behind the feedback the drain consumed, sorted and deduped.
feedback_bot_logins: list[str] = field(default_factory=list)

# Commit SHAs this iteration pushed. Empty unless the outcome is already_pushed.
head_shas: list[str] = field(default_factory=list)


@analytics.eventclass("ai.autofix.pr_iteration.feedback_batch.blocked")
class AiAutofixPrIterationFeedbackBatchBlockedEvent(analytics.Event):
Expand Down
7 changes: 7 additions & 0 deletions src/sentry/seer/autofix/autofix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,13 @@ def get_iterations(state: SeerRunState) -> list[Iteration]:
return iterations


def iteration_repos(iteration: Iteration) -> set[str]:
"""The repositories this iteration changed."""
return {
patch.repo_name for block in iteration.blocks for patch in (block.merged_file_patches or [])
}


def get_latest_iteration_index(state: SeerRunState) -> int:
try:
iterations = get_iterations(state)
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/seer/autofix/on_completion_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
STEP_CONFIGS,
get_iterations,
get_latest_iteration_index,
iteration_repos,
should_open_autofix_pr_as_draft,
trigger_autofix_agent,
trigger_coding_agent_handoff,
Expand Down Expand Up @@ -1125,7 +1126,7 @@ def _latest_iteration_touched_files(
if not iterations:
return True

return any(block.merged_file_patches for block in iterations[-1].blocks)
return bool(iteration_repos(iterations[-1]))

@classmethod
def _pr_iteration_push_outcome(
Expand Down
33 changes: 32 additions & 1 deletion src/sentry/seer/autofix/pr_iteration/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@
)
from sentry.models.group import Group
from sentry.seer.agent.client_models import SeerRunState
from sentry.seer.autofix.autofix_agent import get_latest_iteration_index
from sentry.seer.autofix.autofix_agent import (
get_iterations,
get_latest_iteration_index,
iteration_repos,
)
from sentry.seer.autofix.pr_iteration.current_iteration import triggered_iteration_id
from sentry.seer.autofix.pr_iteration.details_store import (
claim_iteration,
Expand Down Expand Up @@ -284,13 +288,32 @@ def discard_pr_iteration_details(
log_ctx.error("autofix.pr_iteration.details.discard_failed")


def _pushed_head_shas(run_state: SeerRunState) -> list[str]:
"""The commit SHAs the latest iteration pushed, one for each repository."""
try:
iterations = get_iterations(run_state)
except Exception:
return []

if not iterations:
return []

shas = {
pr_state.commit_sha
for repo in iteration_repos(iterations[-1])
if (pr_state := run_state.repo_pr_states.get(repo)) and pr_state.commit_sha
}
return sorted(shas)


def _build_event(
log_ctx: PrIterationLogContext,
iteration: SeerRunPrIteration,
event_cls: type[EventT],
*,
iteration_index: int,
outcome: str,
head_shas: list[str] | None = None,
) -> EventT | None:
"""An event filled from an iteration's row. None when that row is incomplete.

Expand All @@ -305,6 +328,8 @@ class decides how much of that is in scope: a blocked event takes the four
# event reports what the drain wrote instead.
if "duration_ms" in known:
payload["duration_ms"] = int((timezone.now() - iteration.date_added).total_seconds() * 1000)
if head_shas is not None and "head_shas" in known:
payload["head_shas"] = head_shas
try:
return event_cls(
iteration_id=iteration.id,
Expand Down Expand Up @@ -415,12 +440,18 @@ def complete_pr_iteration_details(
log_ctx.info("autofix.pr_iteration.details.skipped", reason="already_emitted")
return

head_shas = (
_pushed_head_shas(run_state)
if outcome == PrIterationOutcome.ALREADY_PUSHED.value
else []
)
event = _build_event(
log_ctx,
iteration,
AiAutofixPrIterationFeedbackBatchCompletedEvent,
iteration_index=get_latest_iteration_index(run_state),
outcome=outcome,
head_shas=head_shas,
)
if event is None or not remove_iteration(iteration):
return
Expand Down
134 changes: 130 additions & 4 deletions tests/sentry/seer/autofix/pr_iteration/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
AiAutofixPrIterationFeedbackBatchBlockedEvent,
AiAutofixPrIterationFeedbackBatchCompletedEvent,
)
from sentry.seer.agent.client_models import MemoryBlock, Message, SeerRunState
from sentry.seer.agent.client_models import (
AgentFilePatch,
FilePatch,
MemoryBlock,
Message,
RepoPRState,
SeerRunState,
)
from sentry.seer.autofix.pr_iteration.details_store import (
open_iterations,
remove_iterations_before,
Expand All @@ -34,18 +41,53 @@
RUN_ID = 4242


def _run_state(*, blocks: list[MemoryBlock] | None = None) -> SeerRunState:
def _run_state(
*,
blocks: list[MemoryBlock] | None = None,
commit_shas: dict[str, str] | None = None,
) -> SeerRunState:
return SeerRunState(
run_id=RUN_ID,
blocks=blocks or [],
status="completed",
updated_at="2024-01-01T00:00:00Z",
repo_pr_states={
repo: RepoPRState(repo_name=repo, commit_sha=sha)
for repo, sha in (commit_shas or {}).items()
},
)


def _patch(repo_name: str) -> AgentFilePatch:
return AgentFilePatch(
repo_name=repo_name,
patch=FilePatch(path="src/foo.py", type="M", added=1, removed=0),
)


def _iteration_block(iteration_id: int) -> MemoryBlock:
def _edit_block(
block_id: str, *, repos: list[str], pr_commit_shas: dict[str, str] | None = None
) -> MemoryBlock:
"""A follow-on block in the iteration that edited files in ``repos``."""
return MemoryBlock(
id=block_id,
pr_commit_shas=pr_commit_shas,
merged_file_patches=[_patch(repo) for repo in repos],
message=Message(role="assistant", content="edit"),
timestamp="2024-01-01T00:00:00Z",
)


def _iteration_block(
iteration_id: int,
*,
repos: list[str] | None = None,
pr_commit_shas: dict[str, str] | None = None,
) -> MemoryBlock:
return MemoryBlock(
id="block-0",
pr_commit_shas=pr_commit_shas,
merged_file_patches=[_patch(repo) for repo in repos or []],
message=Message(
role="assistant",
content="iteration",
Expand Down Expand Up @@ -108,10 +150,16 @@ def _complete(
iteration_id: int,
*,
outcome: str = PrIterationOutcome.ALREADY_PUSHED.value,
repos: list[str] | None = None,
commit_shas: dict[str, str] | None = None,
extra_blocks: list[MemoryBlock] | None = None,
) -> None:
complete_pr_iteration_details(
log_ctx=self.log_ctx,
run_state=_run_state(blocks=[_iteration_block(iteration_id)]),
run_state=_run_state(
blocks=[_iteration_block(iteration_id, repos=repos), *(extra_blocks or [])],
commit_shas=commit_shas,
),
organization_id=self.organization.id,
outcome=outcome,
)
Expand Down Expand Up @@ -140,6 +188,82 @@ def test_the_trigger_writes_what_the_drain_saw(self) -> None:
assert row.data["automated_feedback_count"] == 1
assert row.data["feedback_bot_logins"] == ["coderabbitai[bot]"]

def test_a_pushed_iteration_records_the_commit_it_pushed(self) -> None:
self._open()
iteration_id = self._trigger()
assert iteration_id is not None

with patch("sentry.analytics.record") as mock_record:
self._complete(
iteration_id,
repos=["owner/repo"],
commit_shas={"owner/repo": "sha-new"},
)

assert mock_record.call_args.args[0].head_shas == ["sha-new"]

def test_the_pushed_commit_wins_over_an_earlier_blocks_commit(self) -> None:
"""A block records the PR head at the time it was created, so it can be stale."""
self._open()
iteration_id = self._trigger()
assert iteration_id is not None
stale = _edit_block(
"block-1", repos=["owner/repo"], pr_commit_shas={"owner/repo": "sha-old"}
)
pushed = _edit_block("block-2", repos=["owner/repo"])

with patch("sentry.analytics.record") as mock_record:
self._complete(
iteration_id,
commit_shas={"owner/repo": "sha-new"},
extra_blocks=[stale, pushed],
)

assert mock_record.call_args.args[0].head_shas == ["sha-new"]

def test_a_multi_repo_iteration_records_every_commit_it_pushed(self) -> None:
self._open()
iteration_id = self._trigger()
assert iteration_id is not None

with patch("sentry.analytics.record") as mock_record:
self._complete(
iteration_id,
repos=["owner/one", "owner/two"],
commit_shas={"owner/one": "sha-b", "owner/two": "sha-a"},
)

assert mock_record.call_args.args[0].head_shas == ["sha-a", "sha-b"]

def test_a_repo_the_iteration_did_not_touch_is_left_out(self) -> None:
self._open()
iteration_id = self._trigger()
assert iteration_id is not None

with patch("sentry.analytics.record") as mock_record:
self._complete(
iteration_id,
repos=["owner/one"],
commit_shas={"owner/one": "sha-a", "owner/untouched": "sha-z"},
)

assert mock_record.call_args.args[0].head_shas == ["sha-a"]

def test_an_iteration_that_pushed_nothing_records_no_commit(self) -> None:
self._open()
iteration_id = self._trigger()
assert iteration_id is not None

with patch("sentry.analytics.record") as mock_record:
self._complete(
iteration_id,
outcome=PrIterationOutcome.NO_CODE_CHANGES.value,
repos=["owner/repo"],
commit_shas={"owner/repo": "sha-new"},
)

assert mock_record.call_args.args[0].head_shas == []

@freeze_time("2024-01-01 00:00:00")
def test_the_iteration_it_opened_is_emitted_when_it_completes(self) -> None:
self._open()
Expand All @@ -165,6 +289,7 @@ def test_the_iteration_it_opened_is_emitted_when_it_completes(self) -> None:
dropped_count=1,
automated_feedback_count=1,
feedback_bot_logins=["coderabbitai[bot]"],
head_shas=[],
outcome="already_pushed",
),
)
Expand Down Expand Up @@ -308,6 +433,7 @@ def test_an_iteration_that_produced_nothing_records_that_outcome(self) -> None:
dropped_count=1,
automated_feedback_count=1,
feedback_bot_logins=["coderabbitai[bot]"],
head_shas=[],
outcome="no_code_changes",
),
)
Expand Down
Loading