diff --git a/src/sentry/issues/derived/aggregators.py b/src/sentry/issues/derived/aggregators.py index 11d609b2952d..1cb579b19a35 100644 --- a/src/sentry/issues/derived/aggregators.py +++ b/src/sentry/issues/derived/aggregators.py @@ -73,6 +73,11 @@ def track_views(state: StateView, entry: GroupActionLogEntry) -> AggregatorResul ), ) def track_status(state: StateView, entry: GroupActionLogEntry) -> AggregatorResult: + # A merge preserves the destination group's status. Ignore actions migrated + # from source groups so their history cannot overwrite that status. + if entry.original_group_id is not None: + return None + current = state[STATUS] match entry.action: diff --git a/src/sentry/issues/derived/features.py b/src/sentry/issues/derived/features.py index 7a1b5f9ed718..5716f61eea2c 100644 --- a/src/sentry/issues/derived/features.py +++ b/src/sentry/issues/derived/features.py @@ -16,7 +16,7 @@ class IssueStatus(StrEnum): # Status of the issue based on the log. STATUS = Feature[IssueStatus]( - "status", default=IssueStatus.OPEN, codec=EnumCodec(IssueStatus), version=1 + "status", default=IssueStatus.OPEN, codec=EnumCodec(IssueStatus), version=2 ) # The current Progress of the issue. diff --git a/tests/sentry/issues/derived/test_aggregators.py b/tests/sentry/issues/derived/test_aggregators.py index 4617b500d153..436a673ec917 100644 --- a/tests/sentry/issues/derived/test_aggregators.py +++ b/tests/sentry/issues/derived/test_aggregators.py @@ -65,6 +65,7 @@ class FakeEntry: actor_type: int = GroupActorType.SYSTEM actor_id: int = 0 data: dict[str, object] = field(default_factory=dict) + original_group_id: int | None = None @property def action(self) -> GroupAction: @@ -84,11 +85,12 @@ def _resolved_pr_data(pr_id: int) -> dict[str, object]: return {"pull_request": pr_id} -def _reconcile_entry(status: IssueStatus) -> FakeEntry: +def _reconcile_entry(status: IssueStatus, *, original_group_id: int | None = None) -> FakeEntry: action = ReconcileStatusAction(status=status.value) return FakeEntry( type=GroupActionType.RECONCILE_STATUS, data=action.dict(), + original_group_id=original_group_id, ) @@ -168,6 +170,26 @@ def test_close_actions(action_type: GroupActionType) -> None: ) +@pytest.mark.parametrize( + "action_type", + [ + GroupActionType.RESOLVE, + GroupActionType.SET_RESOLVED_IN_RELEASE, + GroupActionType.SET_RESOLVED_BY_AGE, + GroupActionType.SET_RESOLVED_IN_COMMIT, + GroupActionType.ARCHIVE, + ], +) +def test_close_actions_from_merged_groups_are_ignored(action_type: GroupActionType) -> None: + assert ( + _run_for_feature( + STATUS, + [FakeEntry(type=action_type, original_group_id=123)], + ) + == IssueStatus.OPEN + ) + + def test_unresolve_reopens() -> None: assert ( _run_for_feature( @@ -181,6 +203,27 @@ def test_unresolve_reopens() -> None: ) +@pytest.mark.parametrize( + "action_type", + [ + GroupActionType.UNRESOLVE, + GroupActionType.SET_REGRESSED, + GroupActionType.SET_ESCALATING, + ], +) +def test_reopen_actions_from_merged_groups_are_ignored(action_type: GroupActionType) -> None: + assert ( + _run_for_feature( + STATUS, + [ + FakeEntry(type=GroupActionType.RESOLVE), + FakeEntry(type=action_type, original_group_id=123), + ], + ) + == IssueStatus.CLOSED + ) + + def test_duplicate_resolve_is_noop() -> None: assert ( _run_for_feature( @@ -310,6 +353,18 @@ def test_reopens_closed(self) -> None: == IssueStatus.OPEN ) + def test_from_merged_group_is_ignored(self) -> None: + assert ( + _run_for_feature( + STATUS, + [ + FakeEntry(type=GroupActionType.RESOLVE), + _reconcile_entry(IssueStatus.OPEN, original_group_id=123), + ], + ) + == IssueStatus.CLOSED + ) + def test_same_value_is_noop(self) -> None: assert ( _run_for_feature(