From 1ac4e8fe7bef9f686fa29caf94319a97b7ddeed5 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 29 Oct 2025 10:04:32 -0700 Subject: [PATCH 1/4] feat(eco): link integration resolve activities to GroupResolution for regression detection Integration webhooks that resolve issues in releases were creating SET_RESOLVED_IN_RELEASE activities without linking them to their GroupResolution via the ident field. This prevented event_manager from finding the resolution activity during regression detection, causing follows_semver and resolved_in_version fields to be missing from regression activities. Now we retroactively link the activity after creating the GroupResolution so regressions can properly display semver comparison context. --- .../integrations/tasks/sync_status_inbound.py | 14 ++++++ .../tasks/test_sync_status_inbound.py | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/sentry/integrations/tasks/sync_status_inbound.py b/src/sentry/integrations/tasks/sync_status_inbound.py index 98226977ae2fe9..0035bfe9ca73ec 100644 --- a/src/sentry/integrations/tasks/sync_status_inbound.py +++ b/src/sentry/integrations/tasks/sync_status_inbound.py @@ -14,6 +14,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration import integration_service from sentry.models.group import Group, GroupStatus +from sentry.models.activity import Activity from sentry.models.groupresolution import GroupResolution from sentry.models.organization import Organization from sentry.models.release import Release, ReleaseStatus, follows_semver_versioning_scheme @@ -275,6 +276,19 @@ def sync_status_inbound( if not created: resolution.update(datetime=django_timezone.now(), **resolution_params) + # Link the activity to the resolution so regressions can find it. + # Only applies to SET_RESOLVED_IN_RELEASE activities created above. + if created and activity_type == ActivityType.SET_RESOLVED_IN_RELEASE: + latest_activity = ( + Activity.objects.filter( + group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value + ) + .order_by("-datetime") + .first() + ) + if latest_activity and not latest_activity.ident: + latest_activity.update(ident=resolution.id) + issue_resolved.send_robust( organization_id=organization_id, user=None, diff --git a/tests/sentry/integrations/tasks/test_sync_status_inbound.py b/tests/sentry/integrations/tasks/test_sync_status_inbound.py index d68d8a31c675c4..6e79e5e2c4a0cd 100644 --- a/tests/sentry/integrations/tasks/test_sync_status_inbound.py +++ b/tests/sentry/integrations/tasks/test_sync_status_inbound.py @@ -13,6 +13,7 @@ from sentry.models.group import Group, GroupStatus from sentry.models.grouplink import GroupLink from sentry.models.groupresolution import GroupResolution +from sentry.models.activity import Activity from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode @@ -272,6 +273,51 @@ def test_resolve_current_release(self, mock_get_resolve_sync_action: mock.MagicM self._assert_group_resolved(self.group.id) self._assert_resolve_in_release_activity_created(in_next_release=False) + @mock.patch.object(ExampleIntegration, "get_resolve_sync_action") + def test_resolve_in_release_activity_ident_is_linked(self, mock_get_resolve_sync_action: mock.MagicMock) -> None: + mock_get_resolve_sync_action.return_value = ResolveSyncAction.RESOLVE + + # Ensure there is at least one release so we take the SET_RESOLVED_IN_RELEASE path + self.create_release(project=self.project, version="1.0.0") + + with assume_test_silo_mode(SiloMode.CONTROL): + org_integration = OrganizationIntegration.objects.get( + organization_id=self.organization.id, + integration_id=self.integration.id, + ) + org_integration.update( + config={ + "sync_comments": True, + "sync_status_outbound": True, + "sync_status_inbound": True, + "sync_assignee_outbound": True, + "sync_assignee_inbound": True, + "resolution_strategy": "resolve_current_release", + }, + ) + + sync_status_inbound( + integration_id=self.integration.id, + organization_id=self.organization.id, + issue_key=TEST_ISSUE_KEY, + data=fake_data, + ) + + resolution = GroupResolution.objects.get(group=self.group) + + # The latest SET_RESOLVED_IN_RELEASE activity should have ident == resolution.id + activity = ( + Activity.objects.filter( + group=self.group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value + ) + .order_by("-datetime") + .first() + ) + assert activity is not None + assert activity.ident == str(resolution.id) + + + @mock.patch.object(ExampleIntegration, "get_resolve_sync_action") def test_resolve_no_releases(self, mock_get_resolve_sync_action: mock.MagicMock) -> None: mock_get_resolve_sync_action.return_value = ResolveSyncAction.RESOLVE From 23205fc4069e2e7e522aff988b46ccfe0fde4e18 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 29 Oct 2025 10:08:46 -0700 Subject: [PATCH 2/4] cleanup, combine tests --- .../tasks/test_sync_status_inbound.py | 49 ++++++------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/tests/sentry/integrations/tasks/test_sync_status_inbound.py b/tests/sentry/integrations/tasks/test_sync_status_inbound.py index 6e79e5e2c4a0cd..32485fb0e8b65f 100644 --- a/tests/sentry/integrations/tasks/test_sync_status_inbound.py +++ b/tests/sentry/integrations/tasks/test_sync_status_inbound.py @@ -10,10 +10,10 @@ from sentry.integrations.models import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.tasks.sync_status_inbound import sync_status_inbound +from sentry.models.activity import Activity from sentry.models.group import Group, GroupStatus from sentry.models.grouplink import GroupLink from sentry.models.groupresolution import GroupResolution -from sentry.models.activity import Activity from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode @@ -241,6 +241,18 @@ def test_resolve_next_release(self, mock_get_resolve_sync_action: mock.MagicMock self._assert_group_resolved(self.group.id) self._assert_resolve_in_release_activity_created(in_next_release=True) + # Verify the activity is linked to GroupResolution via ident + resolution = GroupResolution.objects.get(group=self.group) + activity = ( + Activity.objects.filter( + group=self.group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value + ) + .order_by("-datetime") + .first() + ) + assert activity is not None + assert activity.ident == str(resolution.id) + @mock.patch.object(ExampleIntegration, "get_resolve_sync_action") def test_resolve_current_release(self, mock_get_resolve_sync_action: mock.MagicMock) -> None: mock_get_resolve_sync_action.return_value = ResolveSyncAction.RESOLVE @@ -273,39 +285,8 @@ def test_resolve_current_release(self, mock_get_resolve_sync_action: mock.MagicM self._assert_group_resolved(self.group.id) self._assert_resolve_in_release_activity_created(in_next_release=False) - @mock.patch.object(ExampleIntegration, "get_resolve_sync_action") - def test_resolve_in_release_activity_ident_is_linked(self, mock_get_resolve_sync_action: mock.MagicMock) -> None: - mock_get_resolve_sync_action.return_value = ResolveSyncAction.RESOLVE - - # Ensure there is at least one release so we take the SET_RESOLVED_IN_RELEASE path - self.create_release(project=self.project, version="1.0.0") - - with assume_test_silo_mode(SiloMode.CONTROL): - org_integration = OrganizationIntegration.objects.get( - organization_id=self.organization.id, - integration_id=self.integration.id, - ) - org_integration.update( - config={ - "sync_comments": True, - "sync_status_outbound": True, - "sync_status_inbound": True, - "sync_assignee_outbound": True, - "sync_assignee_inbound": True, - "resolution_strategy": "resolve_current_release", - }, - ) - - sync_status_inbound( - integration_id=self.integration.id, - organization_id=self.organization.id, - issue_key=TEST_ISSUE_KEY, - data=fake_data, - ) - + # Verify the activity is linked to GroupResolution via ident resolution = GroupResolution.objects.get(group=self.group) - - # The latest SET_RESOLVED_IN_RELEASE activity should have ident == resolution.id activity = ( Activity.objects.filter( group=self.group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value @@ -316,8 +297,6 @@ def test_resolve_in_release_activity_ident_is_linked(self, mock_get_resolve_sync assert activity is not None assert activity.ident == str(resolution.id) - - @mock.patch.object(ExampleIntegration, "get_resolve_sync_action") def test_resolve_no_releases(self, mock_get_resolve_sync_action: mock.MagicMock) -> None: mock_get_resolve_sync_action.return_value = ResolveSyncAction.RESOLVE From d785837ecb357c814a3f561a07c2339d7040f30d Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 29 Oct 2025 10:32:36 -0700 Subject: [PATCH 3/4] link all resolution activities --- .../integrations/tasks/sync_status_inbound.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/sentry/integrations/tasks/sync_status_inbound.py b/src/sentry/integrations/tasks/sync_status_inbound.py index 0035bfe9ca73ec..8e49038b1d5df2 100644 --- a/src/sentry/integrations/tasks/sync_status_inbound.py +++ b/src/sentry/integrations/tasks/sync_status_inbound.py @@ -13,8 +13,8 @@ from sentry.constants import ObjectStatus from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration import integration_service -from sentry.models.group import Group, GroupStatus from sentry.models.activity import Activity +from sentry.models.group import Group, GroupStatus from sentry.models.groupresolution import GroupResolution from sentry.models.organization import Organization from sentry.models.release import Release, ReleaseStatus, follows_semver_versioning_scheme @@ -277,17 +277,14 @@ def sync_status_inbound( resolution.update(datetime=django_timezone.now(), **resolution_params) # Link the activity to the resolution so regressions can find it. - # Only applies to SET_RESOLVED_IN_RELEASE activities created above. - if created and activity_type == ActivityType.SET_RESOLVED_IN_RELEASE: - latest_activity = ( - Activity.objects.filter( - group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value - ) + if created: + latest_resolution_activity = ( + Activity.objects.filter(group=group, type=activity_type) .order_by("-datetime") .first() ) - if latest_activity and not latest_activity.ident: - latest_activity.update(ident=resolution.id) + if latest_resolution_activity and not latest_resolution_activity.ident: + latest_resolution_activity.update(ident=resolution.id) issue_resolved.send_robust( organization_id=organization_id, From 54c9a5c7a5ea3626cba6bef7005258bc7331efc6 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 29 Oct 2025 10:46:56 -0700 Subject: [PATCH 4/4] fix(eco): use integer activity type when linking ident to resolution Use activity_type.value in the ident-linking query to avoid passing the Enum to the ORM filter. This fixes tests failing with TypeError when filtering by Activity.type. --- src/sentry/integrations/tasks/sync_status_inbound.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/integrations/tasks/sync_status_inbound.py b/src/sentry/integrations/tasks/sync_status_inbound.py index 8e49038b1d5df2..7b117b74a6ab31 100644 --- a/src/sentry/integrations/tasks/sync_status_inbound.py +++ b/src/sentry/integrations/tasks/sync_status_inbound.py @@ -279,7 +279,7 @@ def sync_status_inbound( # Link the activity to the resolution so regressions can find it. if created: latest_resolution_activity = ( - Activity.objects.filter(group=group, type=activity_type) + Activity.objects.filter(group=group, type=activity_type.value) .order_by("-datetime") .first() )