Skip to content
Merged
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
34 changes: 30 additions & 4 deletions src/sentry/integrations/tasks/sync_status_inbound.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from collections.abc import Iterable, Mapping
from datetime import timedelta
from typing import Any

from django.db.models import Q
Expand Down Expand Up @@ -168,6 +169,20 @@ def get_resolutions_and_activity_data_for_groups(
return resolutions_by_group_id, activity_type, activity_data


def group_was_recently_resolved(group: Group) -> bool:
"""
Check if the group was resolved in the last 3 minutes
"""
if group.status != GroupStatus.RESOLVED:
return False

try:
group_resolution = GroupResolution.objects.get(group=group)
return group_resolution.datetime > django_timezone.now() - timedelta(minutes=3)
except GroupResolution.DoesNotExist:
return False


@instrumented_task(
name="sentry.integrations.tasks.sync_status_inbound",
queue="integrations",
Expand All @@ -189,8 +204,8 @@ def sync_status_inbound(
raise Integration.DoesNotExist

organizations = Organization.objects.filter(id=organization_id)
affected_groups = Group.objects.get_groups_by_external_issue(
integration, organizations, issue_key
affected_groups = list(
Group.objects.get_groups_by_external_issue(integration, organizations, issue_key)
)
if not affected_groups:
return
Expand All @@ -213,6 +228,17 @@ def sync_status_inbound(
"integration_id": integration_id,
}
if action == ResolveSyncAction.RESOLVE:
# Check if the group was recently resolved and we should skip the request
# Avoid resolving the group in-app and then re-resolving via the integration webhook
# which would override the in-app resolution
resolvable_groups = []
for group in affected_groups:
if not group_was_recently_resolved(group):
resolvable_groups.append(group)

if not resolvable_groups:
return

(
resolutions_by_group_id,
activity_type,
Expand All @@ -221,14 +247,14 @@ def sync_status_inbound(
affected_groups, config.get("resolution_strategy"), activity_data, organization_id
)
Group.objects.update_group_status(
groups=affected_groups,
groups=resolvable_groups,
status=GroupStatus.RESOLVED,
substatus=None,
activity_type=activity_type,
activity_data=activity_data,
)
# after we update the group, pdate the resolutions
for group in affected_groups:
for group in resolvable_groups:
resolution_params = resolutions_by_group_id.get(group.id)
if resolution_params:
resolution, created = GroupResolution.objects.get_or_create(
Expand Down
75 changes: 75 additions & 0 deletions tests/sentry/integrations/test_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,81 @@ def test_sync_status_resolve_in_next_release_with_releases(self):
"version": release2.version,
}

def test_sync_status_does_not_override_existing_recent_group_resolution(self):
"""
Test that the sync_status_inbound does not override the existing group resolution
if the group was recently resolved
"""
release = Release.objects.create(organization_id=self.project.organization_id, version="a")
release2 = Release.objects.create(organization_id=self.project.organization_id, version="b")
release.add_project(self.project)
release2.add_project(self.project)
group = self.create_group(status=GroupStatus.UNRESOLVED)
# add releases in the reverse order
self.create_group_release(group=group, release=release2)
self.create_group_release(group=group, release=release)

assert group.status == GroupStatus.UNRESOLVED

# Resolve the group in old_release
group.update(status=GroupStatus.RESOLVED, substatus=None)
resolution = GroupResolution.objects.create(release=release, group=group)
assert resolution.current_release_version is None
assert resolution.release == release
activity = Activity.objects.create(
group=group,
project=group.project,
type=ActivityType.SET_RESOLVED_IN_RELEASE.value,
ident=resolution.id,
data={"version": release.version},
)

with assume_test_silo_mode(SiloMode.CONTROL):
integration = self.create_provider_integration(provider="example", external_id="123456")
integration.add_organization(group.organization, self.user)

for oi in OrganizationIntegration.objects.filter(
integration_id=integration.id, organization_id=group.organization.id
):
oi.update(
config={
"sync_comments": True,
"sync_status_outbound": True,
"sync_status_inbound": True,
"sync_assignee_outbound": True,
"sync_assignee_inbound": True,
"resolution_strategy": "resolve_next_release",
}
)

external_issue = ExternalIssue.objects.create(
organization_id=group.organization.id, integration_id=integration.id, key="APP-123"
)

GroupLink.objects.create(
group_id=group.id,
project_id=group.project_id,
linked_type=GroupLink.LinkedType.issue,
linked_id=external_issue.id,
relationship=GroupLink.Relationship.references,
)

installation = integration.get_installation(group.organization.id)
assert isinstance(installation, ExampleIntegration)

with self.feature("organizations:integrations-issue-sync"), self.tasks():
installation.sync_status_inbound(
external_issue.key,
{"project_id": "APP", "status": {"id": "12345", "category": "done"}},
)

assert Group.objects.get(id=group.id).status == GroupStatus.RESOLVED
resolution.refresh_from_db()
assert resolution.release == release
assert resolution.current_release_version is None
activity.refresh_from_db()
assert activity.data["version"] == release.version

def test_sync_status_resolve_in_next_release_with_semver(self):
release = Release.objects.create(
organization_id=self.project.organization_id, version="app@1.2.4"
Expand Down
Loading