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
30 changes: 24 additions & 6 deletions src/sentry/issues/derived/aggregators.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,12 @@

# Progress for open issues (None when closed).
#
# Progress is dervived from a few features, which are tracked independently:
# Progress is derived from a few features, which are tracked independently:
#
# * IS_ASSIGNED — issue has an assignee. Survives close/reopen.
# * HAS_ROOT_CAUSE — a root cause has been identified (diagnosed). Cleared on
# regression (SET_REGRESSED), but preserved when manually reopened (UNRESOLVE).
# regression (SET_REGRESSED) and when a Seer-proposed fix is rejected, but
# preserved when manually reopened (UNRESOLVE).
# * HAS_OPEN_FIX_PR — at least one PR which resolves the issue is still open.
# Cleared when the last linked PR closes without being merged.
#
Expand Down Expand Up @@ -133,22 +134,39 @@

@aggregator(
(HAS_ROOT_CAUSE,),
deps=(LAST_COMPLETED_AUTOFIX_STEP,),
scope=(
RootCauseIdentifiedAction,
SeerRCACompletedAction,
SetRegressedAction,
PullRequestClosedAction,
),
)
def track_root_cause(state: StateView, entry: GroupActionLogEntry) -> AggregatorResult:
"""Track whether the issue has a root cause identified (i.e. is diagnosed).

Set by ROOT_CAUSE_IDENTIFIED or SEER_RCA_COMPLETED and cleared on regression
(SET_REGRESSED), since a regressed issue is a fresh occurrence that needs
re-diagnosing. A manual reopen (UNRESOLVE) preserves the diagnosis.
re-diagnosing. Also cleared when a Seer fix PR closes unmerged: the rejected
fix takes its diagnosis with it. A manual reopen (UNRESOLVE) preserves the
diagnosis.
"""
has_root_cause = isinstance(entry.action, (RootCauseIdentifiedAction, SeerRCACompletedAction))
if has_root_cause != state[HAS_ROOT_CAUSE]:
return emit(HAS_ROOT_CAUSE.value(has_root_cause))
current = state[HAS_ROOT_CAUSE]
# Per-issue, not per-PR: SeerPRCreatedAction carries raw PR payloads rather
# than PullRequest ids, so a closed PR can't be traced back to a Seer run.
seer_opened_a_pr = state[LAST_COMPLETED_AUTOFIX_STEP] in (
IssueAutofixStep.PR_CREATED,
IssueAutofixStep.PR_ITERATION,
)

match entry.action:
case RootCauseIdentifiedAction() | SeerRCACompletedAction() if not current:
return emit(HAS_ROOT_CAUSE.value(True))
case SetRegressedAction() if current:
return emit(HAS_ROOT_CAUSE.value(False))
case PullRequestClosedAction(has_other_open_prs=False) if current and seer_opened_a_pr:
return emit(HAS_ROOT_CAUSE.value(False))

Check warning on line 168 in src/sentry/issues/derived/aggregators.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Root cause cleared for non-Seer PR closures after Seer workflow completed

`LAST_COMPLETED_AUTOFIX_STEP` tracks the furthest Seer step ever reached and never resets, so once Seer creates a PR the `seer_opened_a_pr` guard stay True forever. Any later `PullRequestClosedAction(has_other_open_prs=False)` — even for an unrelated user PR — will clear the root cause.
Comment on lines +156 to +168

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root cause cleared for non-Seer PR closures after Seer workflow completed

LAST_COMPLETED_AUTOFIX_STEP tracks the furthest Seer step ever reached and never resets, so once Seer creates a PR the seer_opened_a_pr guard stay True forever. Any later PullRequestClosedAction(has_other_open_prs=False) — even for an unrelated user PR — will clear the root cause.

Evidence
  • track_last_completed_autofix_step only advances LAST_COMPLETED_AUTOFIX_STEP; once it reaches PR_CREATED or PR_ITERATION it stays there permanently (lines 249-267).
  • track_root_cause reads that same feature to decide whether Seer ever created a PR: state[LAST_COMPLETED_AUTOFIX_STEP] in (IssueAutofixStep.PR_CREATED, IssueAutofixStep.PR_ITERATION) (line 157).
  • When a PullRequestClosedAction(has_other_open_prs=False) arrives and current is True, root cause is unconditionally cleared if that guard is True (lines 167-168).
  • Example trigger: Seer PR is merged (issue resolves) → issue is reopened → user creates an unrelated PR and closes it (no other open PRs) → root cause is incorrectly cleared, even though the Seer diagnosis was correct.
  • The code comment acknowledges the per-issue limitation but the permanent nature of LAST_COMPLETED_AUTOFIX_STEP makes the heuristic over-clear root cause for any future PR closure.

Identified by Warden · sentry-backend-bugs · HHH-TJ5


return None


Expand Down
2 changes: 1 addition & 1 deletion src/sentry/issues/derived/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class IssueStatus(StrEnum):
IS_ASSIGNED = Feature[bool]("is_assigned", default=False)

# Whether the issue has a root cause identified.
HAS_ROOT_CAUSE = Feature[bool]("has_root_cause", default=False)
HAS_ROOT_CAUSE = Feature[bool]("has_root_cause", default=False, version=1)

# The furthest autofix step the issue has reached, from the latest completed
LAST_COMPLETED_AUTOFIX_STEP = Feature[IssueAutofixStep](
Expand Down
96 changes: 96 additions & 0 deletions tests/sentry/issues/derived/test_aggregators.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from sentry.issues.derived.aggregators import AGGREGATORS
from sentry.issues.derived.features import (
BLOCKER,
HAS_ROOT_CAUSE,
LAST_COMPLETED_AUTOFIX_STEP,
LAST_PROGRESSED_AT,
PROGRESS,
Expand Down Expand Up @@ -100,6 +101,15 @@ def _pr_closed(has_other: bool | None = None, *, pr_id: int = 101, hour: int = 0
return FakeEntry(type=GroupActionType.PULL_REQUEST_CLOSED, date_added=_ts(hour=hour), data=data)


def _seer_fix_proposed() -> list[FakeEntry]:
"""Seer's route to fix_proposed. SEER_PR_CREATED is what marks the PR as Seer's."""
return [
FakeEntry(type=GroupActionType.SEER_RCA_COMPLETED),
FakeEntry(type=GroupActionType.SEER_PR_CREATED),
FakeEntry(type=GroupActionType.RESOLVED_IN_PULL_REQUEST, data=_resolved_pr_data(101)),
]


def _pr_terminal(action: GroupActionType, has_other: bool | None) -> FakeEntry:
data: dict[str, object] = {"pull_request": 101}
if has_other is not None:
Expand Down Expand Up @@ -952,6 +962,92 @@ def test_repropose_after_demotion_returns_to_fix_proposed() -> None:
)


# ---------------------------------------------------------------------------
# A rejected Seer fix invalidates the diagnosis
# ---------------------------------------------------------------------------


def test_seer_pr_close_demotes_to_assigned_when_assigned() -> None:
assert (
_run_for_feature(
PROGRESS,
[
FakeEntry(type=GroupActionType.ASSIGN),
*_seer_fix_proposed(),
_pr_closed(has_other=False),
],
)
== IssueProgressState.ASSIGNED
)


@pytest.mark.parametrize("has_other", [True, None])
def test_seer_pr_close_with_remaining_or_unknown_prs_preserves_root_cause(
has_other: bool | None,
) -> None:
entries = [*_seer_fix_proposed(), _pr_closed(has_other=has_other)]
assert _run_for_feature(HAS_ROOT_CAUSE, entries) is True


@pytest.mark.parametrize(
"action_type",
[
GroupActionType.PULL_REQUEST_MERGED,
GroupActionType.PULL_REQUEST_UNLINKED,
],
)
def test_seer_pr_merged_or_unlinked_preserves_root_cause(action_type: int) -> None:
# Merging is progress and unlinking is bookkeeping; neither rejects the fix.
entries = [
*_seer_fix_proposed(),
FakeEntry(type=action_type, data={"pull_request": 101, "has_other_open_prs": False}),
]
assert _run_for_feature(HAS_ROOT_CAUSE, entries) is True


def test_human_pr_close_preserves_root_cause() -> None:
# Seer never opened a PR here, so a closed PR says nothing about the diagnosis.
entries = [
FakeEntry(type=GroupActionType.ROOT_CAUSE_IDENTIFIED),
FakeEntry(type=GroupActionType.RESOLVED_IN_PULL_REQUEST, data=_resolved_pr_data(101)),
_pr_closed(has_other=False),
]
assert _run_for_feature(HAS_ROOT_CAUSE, entries) is True


def test_seer_rerun_after_rejected_fix_restores_diagnosed() -> None:
assert (
_run_for_feature(
PROGRESS,
[
*_seer_fix_proposed(),
_pr_closed(has_other=False),
FakeEntry(type=GroupActionType.SEER_RCA_COMPLETED),
],
)
== IssueProgressState.DIAGNOSED
)


def test_seer_pr_close_demotes_and_reopen_keeps_diagnosis_cleared() -> None:
p = _pipeline(targets=(PROGRESS,))
state = p.run(_seer_fix_proposed())
assert state[PROGRESS] == IssueProgressState.FIX_PROPOSED

state = p.step(state, _pr_closed(has_other=False))
assert state[PROGRESS] == IssueProgressState.IDENTIFIED

# Reopening restores the open-PR flag, but the diagnosis stays cleared.
state = p.step(
state,
FakeEntry(type=GroupActionType.PULL_REQUEST_REOPENED, data={"pull_request": 101}),
)
assert state[PROGRESS] == IssueProgressState.FIX_PROPOSED

state = p.step(state, _pr_closed(has_other=False))
assert state[PROGRESS] == IssueProgressState.IDENTIFIED


def test_last_progressed_at_updated_on_demotion() -> None:
p = _pipeline(targets=(PROGRESS,))
state = p.initial_state()
Expand Down
Loading