From 9546f6f9b7dd283b1ee19143707605a463c87ac7 Mon Sep 17 00:00:00 2001 From: Snigdha Sharma Date: Tue, 22 Apr 2025 10:19:44 -0700 Subject: [PATCH 1/3] Move open period logic to groupopenperiod.py --- src/sentry/api/helpers/group_index/update.py | 3 +- src/sentry/issues/endpoints/group_details.py | 3 +- .../issues/endpoints/group_open_periods.py | 2 +- src/sentry/issues/status_change.py | 3 +- src/sentry/models/group.py | 188 +---------------- src/sentry/models/groupopenperiod.py | 191 ++++++++++++++++++ src/sentry/tasks/auto_resolve_issues.py | 3 +- .../endpoints/test_group_open_periods.py | 3 +- 8 files changed, 204 insertions(+), 192 deletions(-) diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index b87e40a29b464b..0b5de1979ee6c7 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -31,13 +31,14 @@ from sentry.issues.update_inbox import update_inbox from sentry.models.activity import Activity, ActivityIntegration from sentry.models.commit import Commit -from sentry.models.group import STATUS_UPDATE_CHOICES, Group, GroupStatus, update_group_open_period +from sentry.models.group import STATUS_UPDATE_CHOICES, Group, GroupStatus from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.grouphash import GroupHash from sentry.models.grouphistory import record_group_history_from_activity_type from sentry.models.groupinbox import GroupInboxRemoveAction, remove_group_from_inbox from sentry.models.grouplink import GroupLink +from sentry.models.groupopenperiod import update_group_open_period from sentry.models.groupresolution import GroupResolution from sentry.models.groupseen import GroupSeen from sentry.models.groupshare import GroupShare diff --git a/src/sentry/issues/endpoints/group_details.py b/src/sentry/issues/endpoints/group_details.py index fe4856fb6e6c2f..4e594f7e378c1d 100644 --- a/src/sentry/issues/endpoints/group_details.py +++ b/src/sentry/issues/endpoints/group_details.py @@ -31,9 +31,10 @@ from sentry.issues.grouptype import GroupCategory from sentry.models.activity import Activity from sentry.models.eventattachment import EventAttachment -from sentry.models.group import Group, get_open_periods_for_group +from sentry.models.group import Group from sentry.models.groupinbox import get_inbox_details from sentry.models.grouplink import GroupLink +from sentry.models.groupopenperiod import get_open_periods_for_group from sentry.models.groupowner import get_owner_details from sentry.models.groupseen import GroupSeen from sentry.models.groupsubscription import GroupSubscriptionManager diff --git a/src/sentry/issues/endpoints/group_open_periods.py b/src/sentry/issues/endpoints/group_open_periods.py index fef115a3280bd1..3634adeff48ba7 100644 --- a/src/sentry/issues/endpoints/group_open_periods.py +++ b/src/sentry/issues/endpoints/group_open_periods.py @@ -13,7 +13,7 @@ from sentry.api.paginator import GenericOffsetPaginator from sentry.api.utils import get_date_range_from_params from sentry.exceptions import InvalidParams -from sentry.models.group import get_open_periods_for_group +from sentry.models.groupopenperiod import get_open_periods_for_group if TYPE_CHECKING: from sentry.models.group import Group diff --git a/src/sentry/issues/status_change.py b/src/sentry/issues/status_change.py index 6a550bc399c7f6..a90fb233b51673 100644 --- a/src/sentry/issues/status_change.py +++ b/src/sentry/issues/status_change.py @@ -13,8 +13,9 @@ from sentry.issues.ignored import IGNORED_CONDITION_FIELDS from sentry.issues.ongoing import TRANSITION_AFTER_DAYS from sentry.models.activity import Activity -from sentry.models.group import Group, GroupStatus, update_group_open_period +from sentry.models.group import Group, GroupStatus from sentry.models.grouphistory import record_group_history_from_activity_type +from sentry.models.groupopenperiod import update_group_open_period from sentry.models.groupsubscription import GroupSubscription from sentry.models.project import Project from sentry.notifications.types import GroupSubscriptionReason diff --git a/src/sentry/models/group.py b/src/sentry/models/group.py index 556a95068b4305..202a4ab911092b 100644 --- a/src/sentry/models/group.py +++ b/src/sentry/models/group.py @@ -21,7 +21,7 @@ from django.utils.translation import gettext_lazy as _ from snuba_sdk import Column, Condition, Op -from sentry import eventstore, eventtypes, features, options, tagstore +from sentry import eventstore, eventtypes, options, tagstore from sentry.backup.scopes import RelocationScope from sentry.constants import DEFAULT_LOGGER_NAME, LOG_LEVELS, MAX_CULPRIT_LENGTH from sentry.db.models import ( @@ -42,10 +42,8 @@ PriorityChangeReason, get_priority_for_ongoing_group, ) -from sentry.models.activity import Activity from sentry.models.commit import Commit from sentry.models.grouphistory import record_group_history, record_group_history_from_activity_type -from sentry.models.groupopenperiod import GroupOpenPeriod from sentry.models.organization import Organization from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer @@ -62,7 +60,6 @@ from sentry.utils.strings import strip, truncatechars if TYPE_CHECKING: - from sentry.incidents.utils.metric_issue_poc import OpenPeriod from sentry.integrations.services.integration import RpcIntegration from sentry.models.environment import Environment from sentry.models.team import Team @@ -448,6 +445,7 @@ def update_group_status( ) -> None: """For each groups, update status to `status` and create an Activity.""" from sentry.models.activity import Activity + from sentry.models.groupopenperiod import update_group_open_period modified_groups_list = [] selected_groups = Group.objects.filter(id__in=[g.id for g in groups]).exclude( @@ -1063,185 +1061,3 @@ def pre_save_group_default_substatus(instance, sender, *args, **kwargs): "No substatus allowed for group", extra={"status": instance.status, "substatus": instance.substatus}, ) - - -def get_last_checked_for_open_period(group: Group) -> datetime: - from sentry.incidents.models.alert_rule import AlertRule - from sentry.issues.grouptype import MetricIssuePOC - - event = group.get_latest_event() - last_checked = group.last_seen - if event and group.type == MetricIssuePOC.type_id: - alert_rule_id = event.data.get("contexts", {}).get("metric_alert", {}).get("alert_rule_id") - if alert_rule_id: - try: - alert_rule = AlertRule.objects.get(id=alert_rule_id) - now = timezone.now() - last_checked = now - timedelta(seconds=alert_rule.snuba_query.time_window) - except AlertRule.DoesNotExist: - pass - - return last_checked - - -def get_open_periods_for_group( - group: Group, - query_start: datetime | None = None, - query_end: datetime | None = None, - offset: int | None = None, - limit: int | None = None, -) -> list[OpenPeriod]: - from sentry.incidents.utils.metric_issue_poc import OpenPeriod - from sentry.models.groupopenperiod import GroupOpenPeriod - - if not features.has("organizations:issue-open-periods", group.organization): - return [] - - # Try to get open periods from the GroupOpenPeriod table first - group_open_periods = GroupOpenPeriod.objects.filter(group=group) - if group_open_periods.exists() and query_start: - group_open_periods = group_open_periods.filter( - date_started__gte=query_start, date_ended__lte=query_end, id__gte=offset or 0 - ).order_by("-date_started")[:limit] - - return [ - OpenPeriod( - start=period.date_started, - end=period.date_ended, - duration=period.date_ended - period.date_started if period.date_ended else None, - is_open=period.date_ended is None, - last_checked=get_last_checked_for_open_period(group), - ) - for period in group_open_periods - ] - - # If there are no open periods in the table, we need to calculate them - # from the activity log. - # TODO(snigdha): This is temporary until we have backfilled the GroupOpenPeriod table - - if query_start is None or query_end is None: - query_start = timezone.now() - timedelta(days=90) - query_end = timezone.now() - - query_limit = limit * 2 if limit else None - # Filter to REGRESSION and RESOLVED activties to find the bounds of each open period. - # The only UNRESOLVED activity we would care about is the first UNRESOLVED activity for the group creation, - # but we don't create an entry for that . - activities = Activity.objects.filter( - group=group, - type__in=[ActivityType.SET_REGRESSION.value, ActivityType.SET_RESOLVED.value], - datetime__gte=query_start, - datetime__lte=query_end, - ).order_by("-datetime")[:query_limit] - - open_periods = [] - start: datetime | None = None - end: datetime | None = None - last_checked = get_last_checked_for_open_period(group) - - # Handle currently open period - if group.status == GroupStatus.UNRESOLVED and len(activities) > 0: - open_periods.append( - OpenPeriod( - start=activities[0].datetime, - end=None, - duration=None, - is_open=True, - last_checked=last_checked, - ) - ) - activities = activities[1:] - - for activity in activities: - if activity.type == ActivityType.SET_RESOLVED.value: - end = activity.datetime - elif activity.type == ActivityType.SET_REGRESSION.value: - start = activity.datetime - if end is not None: - open_periods.append( - OpenPeriod( - start=start, - end=end, - duration=end - start, - is_open=False, - last_checked=end, - ) - ) - end = None - - # Add the very first open period, which has no UNRESOLVED activity for the group creation - open_periods.append( - OpenPeriod( - start=group.first_seen, - end=end if end else None, - duration=end - group.first_seen if end else None, - is_open=False if end else True, - last_checked=end if end else last_checked, - ) - ) - - if offset and limit: - return open_periods[offset : offset + limit] - - if limit: - return open_periods[:limit] - - return open_periods - - -def update_group_open_period( - group: Group, - new_status: int, - activity: Activity | None, - should_reopen_open_period: bool, -) -> None: - """ - Update an existing open period when the group is resolved or unresolved. - - On resolution, we set the date_ended to the resolution time and link the activity to the open period. - On unresolved, we clear the date_ended and resolution_activity fields. This is only done if the group - is unresolved manually without a regression. If the group is unresolved due to a regression, the - open periods will be updated during ingestion. - """ - if not features.has("organizations:issue-open-periods", group.project.organization): - return - - if new_status not in (GroupStatus.RESOLVED, GroupStatus.UNRESOLVED): - return - - find_open = new_status != GroupStatus.UNRESOLVED - open_period = ( - GroupOpenPeriod.objects.filter(group=group, date_ended__isnull=find_open) - .order_by("-date_started") - .first() - ) - if not open_period: - logger.error( - "Unable to update open period, no open period found", - extra={"group_id": group.id}, - ) - return - - if new_status == GroupStatus.RESOLVED: - if activity is None: - logger.warning( - "Missing activity for group resolution, querying for it", - extra={"group_id": group.id}, - ) - activity = ( - Activity.objects.filter( - group=group, - type=ActivityType.SET_RESOLVED.value, - ) - .order_by("-datetime") - .first() - ) - - end_time = group.resolved_at or (activity.datetime if activity else None) - open_period.update( - date_ended=end_time, - resolution_activity=activity, - user_id=activity.user_id if activity else None, - ) - elif new_status == GroupStatus.UNRESOLVED and should_reopen_open_period: - open_period.update(date_ended=None, resolution_activity=None, user_id=None) diff --git a/src/sentry/models/groupopenperiod.py b/src/sentry/models/groupopenperiod.py index 21eb1c7dbcb1b5..d1cf419aec5061 100644 --- a/src/sentry/models/groupopenperiod.py +++ b/src/sentry/models/groupopenperiod.py @@ -1,11 +1,21 @@ +import logging +from datetime import datetime, timedelta +from typing import Any + from django.conf import settings from django.contrib.postgres.fields import DateTimeRangeField from django.db import models from django.utils import timezone +from sentry import features from sentry.backup.scopes import RelocationScope from sentry.db.models import DefaultFieldsModel, FlexibleForeignKey, region_silo_model, sane_repr from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.models.activity import Activity +from sentry.models.group import Group, GroupStatus +from sentry.types.activity import ActivityType + +logger = logging.getLogger(__name__) class TsTzRange(models.Func): @@ -60,3 +70,184 @@ class Meta: # ) __repr__ = sane_repr("project_id", "group_id", "date_started", "date_ended", "user_id") + + +def get_last_checked_for_open_period(group: Group) -> datetime: + from sentry.incidents.models.alert_rule import AlertRule + from sentry.issues.grouptype import MetricIssuePOC + + event = group.get_latest_event() + last_checked = group.last_seen + if event and group.type == MetricIssuePOC.type_id: + alert_rule_id = event.data.get("contexts", {}).get("metric_alert", {}).get("alert_rule_id") + if alert_rule_id: + try: + alert_rule = AlertRule.objects.get(id=alert_rule_id) + now = timezone.now() + last_checked = now - timedelta(seconds=alert_rule.snuba_query.time_window) + except AlertRule.DoesNotExist: + pass + + return last_checked + + +def get_open_periods_for_group( + group: Group, + query_start: datetime | None = None, + query_end: datetime | None = None, + offset: int | None = None, + limit: int | None = None, +) -> list[Any]: + from sentry.incidents.utils.metric_issue_poc import OpenPeriod + + if not features.has("organizations:issue-open-periods", group.organization): + return [] + + # Try to get open periods from the GroupOpenPeriod table first + group_open_periods = GroupOpenPeriod.objects.filter(group=group) + if group_open_periods.exists() and query_start: + group_open_periods = group_open_periods.filter( + date_started__gte=query_start, date_ended__lte=query_end, id__gte=offset or 0 + ).order_by("-date_started")[:limit] + + return [ + OpenPeriod( + start=period.date_started, + end=period.date_ended, + duration=period.date_ended - period.date_started if period.date_ended else None, + is_open=period.date_ended is None, + last_checked=get_last_checked_for_open_period(group), + ) + for period in group_open_periods + ] + + # If there are no open periods in the table, we need to calculate them + # from the activity log. + # TODO(snigdha): This is temporary until we have backfilled the GroupOpenPeriod table + + if query_start is None or query_end is None: + query_start = timezone.now() - timedelta(days=90) + query_end = timezone.now() + + query_limit = limit * 2 if limit else None + # Filter to REGRESSION and RESOLVED activties to find the bounds of each open period. + # The only UNRESOLVED activity we would care about is the first UNRESOLVED activity for the group creation, + # but we don't create an entry for that . + activities = Activity.objects.filter( + group=group, + type__in=[ActivityType.SET_REGRESSION.value, ActivityType.SET_RESOLVED.value], + datetime__gte=query_start, + datetime__lte=query_end, + ).order_by("-datetime")[:query_limit] + + open_periods = [] + start: datetime | None = None + end: datetime | None = None + last_checked = get_last_checked_for_open_period(group) + + # Handle currently open period + if group.status == GroupStatus.UNRESOLVED and len(activities) > 0: + open_periods.append( + OpenPeriod( + start=activities[0].datetime, + end=None, + duration=None, + is_open=True, + last_checked=last_checked, + ) + ) + activities = activities[1:] + + for activity in activities: + if activity.type == ActivityType.SET_RESOLVED.value: + end = activity.datetime + elif activity.type == ActivityType.SET_REGRESSION.value: + start = activity.datetime + if end is not None: + open_periods.append( + OpenPeriod( + start=start, + end=end, + duration=end - start, + is_open=False, + last_checked=end, + ) + ) + end = None + + # Add the very first open period, which has no UNRESOLVED activity for the group creation + open_periods.append( + OpenPeriod( + start=group.first_seen, + end=end if end else None, + duration=end - group.first_seen if end else None, + is_open=False if end else True, + last_checked=end if end else last_checked, + ) + ) + + if offset and limit: + return open_periods[offset : offset + limit] + + if limit: + return open_periods[:limit] + + return open_periods + + +def update_group_open_period( + group: Group, + new_status: int, + activity: Activity | None, + should_reopen_open_period: bool, +) -> None: + """ + Update an existing open period when the group is resolved or unresolved. + + On resolution, we set the date_ended to the resolution time and link the activity to the open period. + On unresolved, we clear the date_ended and resolution_activity fields. This is only done if the group + is unresolved manually without a regression. If the group is unresolved due to a regression, the + open periods will be updated during ingestion. + """ + if not features.has("organizations:issue-open-periods", group.project.organization): + return + + if new_status not in (GroupStatus.RESOLVED, GroupStatus.UNRESOLVED): + return + + find_open = new_status != GroupStatus.UNRESOLVED + open_period = ( + GroupOpenPeriod.objects.filter(group=group, date_ended__isnull=find_open) + .order_by("-date_started") + .first() + ) + if not open_period: + logger.error( + "Unable to update open period, no open period found", + extra={"group_id": group.id}, + ) + return + + if new_status == GroupStatus.RESOLVED: + if activity is None: + logger.warning( + "Missing activity for group resolution, querying for it", + extra={"group_id": group.id}, + ) + activity = ( + Activity.objects.filter( + group=group, + type=ActivityType.SET_RESOLVED.value, + ) + .order_by("-datetime") + .first() + ) + + end_time = group.resolved_at or (activity.datetime if activity else None) + open_period.update( + date_ended=end_time, + resolution_activity=activity, + user_id=activity.user_id if activity else None, + ) + elif new_status == GroupStatus.UNRESOLVED and should_reopen_open_period: + open_period.update(date_ended=None, resolution_activity=None, user_id=None) diff --git a/src/sentry/tasks/auto_resolve_issues.py b/src/sentry/tasks/auto_resolve_issues.py index a776e8fe4a7f26..9ff8215e59f682 100644 --- a/src/sentry/tasks/auto_resolve_issues.py +++ b/src/sentry/tasks/auto_resolve_issues.py @@ -11,9 +11,10 @@ from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs from sentry.issues import grouptype from sentry.models.activity import Activity -from sentry.models.group import Group, GroupStatus, update_group_open_period +from sentry.models.group import Group, GroupStatus from sentry.models.grouphistory import GroupHistoryStatus, record_group_history from sentry.models.groupinbox import GroupInboxRemoveAction, remove_group_from_inbox +from sentry.models.groupopenperiod import update_group_open_period from sentry.models.options.project_option import ProjectOption from sentry.models.project import Project from sentry.signals import issue_resolved diff --git a/tests/sentry/issues/endpoints/test_group_open_periods.py b/tests/sentry/issues/endpoints/test_group_open_periods.py index bc8340e331b72d..28c44172ab2fe1 100644 --- a/tests/sentry/issues/endpoints/test_group_open_periods.py +++ b/tests/sentry/issues/endpoints/test_group_open_periods.py @@ -4,7 +4,8 @@ from sentry.issues.grouptype import MetricIssuePOC, ProfileFileIOGroupType from sentry.models.activity import Activity -from sentry.models.group import GroupStatus, get_open_periods_for_group +from sentry.models.group import GroupStatus +from sentry.models.groupopenperiod import get_open_periods_for_group from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature from sentry.types.activity import ActivityType From 58e4a8d65a17d9eb6f11632d2fecc448bc9986cf Mon Sep 17 00:00:00 2001 From: Snigdha Sharma Date: Tue, 22 Apr 2025 10:21:34 -0700 Subject: [PATCH 2/3] Only update open periods if the initial open period is present --- src/sentry/event_manager.py | 6 ++++-- src/sentry/models/groupopenperiod.py | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py index 44e3dbce688799..60eea6befe0452 100644 --- a/src/sentry/event_manager.py +++ b/src/sentry/event_manager.py @@ -88,7 +88,7 @@ from sentry.models.grouphash import GroupHash from sentry.models.grouphistory import GroupHistoryStatus, record_group_history from sentry.models.grouplink import GroupLink -from sentry.models.groupopenperiod import GroupOpenPeriod +from sentry.models.groupopenperiod import GroupOpenPeriod, has_initial_open_period from sentry.models.grouprelease import GroupRelease from sentry.models.groupresolution import GroupResolution from sentry.models.organization import Organization @@ -1715,7 +1715,9 @@ def _handle_regression(group: Group, event: BaseEvent, release: Release | None) kick_off_status_syncs.apply_async( kwargs={"project_id": group.project_id, "group_id": group.id} ) - if features.has("organizations:issue-open-periods", group.project.organization): + if features.has( + "organizations:issue-open-periods", group.project.organization + ) and has_initial_open_period(group): GroupOpenPeriod.objects.create( group=group, project_id=group.project_id, diff --git a/src/sentry/models/groupopenperiod.py b/src/sentry/models/groupopenperiod.py index d1cf419aec5061..1b6bb27301a660 100644 --- a/src/sentry/models/groupopenperiod.py +++ b/src/sentry/models/groupopenperiod.py @@ -212,6 +212,11 @@ def update_group_open_period( if not features.has("organizations:issue-open-periods", group.project.organization): return + # Until we've backfilled the GroupOpenPeriod table, we don't want to update open periods for + # groups that weren't initially created with one. + if not has_initial_open_period(group): + return + if new_status not in (GroupStatus.RESOLVED, GroupStatus.UNRESOLVED): return @@ -251,3 +256,7 @@ def update_group_open_period( ) elif new_status == GroupStatus.UNRESOLVED and should_reopen_open_period: open_period.update(date_ended=None, resolution_activity=None, user_id=None) + + +def has_initial_open_period(group: Group) -> bool: + return GroupOpenPeriod.objects.filter(group=group, date_started__lte=group.first_seen).exists() From cf0d41439c64caaabc6571450725896f6cd74266 Mon Sep 17 00:00:00 2001 From: Snigdha Sharma Date: Tue, 22 Apr 2025 17:44:53 -0700 Subject: [PATCH 3/3] Add tests to check groups without open periods don't have updates --- tests/sentry/api/helpers/test_group_index.py | 23 +++++++++++ .../event_manager/test_event_manager.py | 32 +++++++++++++++ .../test_organization_group_index.py | 40 +++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index 180695d19f81e7..4da6c365c5d2c6 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -146,6 +146,29 @@ def test_resolving_unresolved_group(self, send_robust: Mock) -> None: open_period.refresh_from_db() assert open_period.date_ended is not None + @patch("sentry.signals.issue_resolved.send_robust") + @with_feature("organizations:issue-open-periods") + def test_resolving_unresolved_group_without_open_period(self, send_robust: Mock) -> None: + unresolved_group = self.create_group(status=GroupStatus.UNRESOLVED) + add_group_to_inbox(unresolved_group, GroupInboxReason.NEW) + assert unresolved_group.status == GroupStatus.UNRESOLVED + GroupOpenPeriod.objects.all().delete() + + request = self.make_request(user=self.user, method="GET") + request.user = self.user + request.data = {"status": "resolved", "substatus": None} + request.GET = QueryDict(query_string=f"id={unresolved_group.id}") + + group_list = get_group_list(self.organization.id, [self.project], request.GET.getlist("id")) + update_groups(request, group_list) + + unresolved_group.refresh_from_db() + + assert unresolved_group.status == GroupStatus.RESOLVED + assert not GroupInbox.objects.filter(group=unresolved_group).exists() + assert send_robust.called + assert GroupOpenPeriod.objects.filter(group=unresolved_group).count() == 0 + @patch("sentry.signals.issue_ignored.send_robust") @patch("sentry.issues.status_change.post_save") def test_ignoring_group_archived_forever(self, post_save: Mock, send_robust: Mock) -> None: diff --git a/tests/sentry/event_manager/test_event_manager.py b/tests/sentry/event_manager/test_event_manager.py index eb18905cedb84f..1130c2352aaac3 100644 --- a/tests/sentry/event_manager/test_event_manager.py +++ b/tests/sentry/event_manager/test_event_manager.py @@ -288,6 +288,38 @@ def test_unresolves_group(self, send_robust: mock.MagicMock) -> None: assert open_period.date_started == group.first_seen assert open_period.date_ended == resolved_at + @mock.patch("sentry.signals.issue_unresolved.send_robust") + @with_feature("organizations:issue-open-periods") + def test_unresolves_group_without_open_period(self, send_robust: mock.MagicMock) -> None: + ts = before_now(minutes=5).isoformat() + + # N.B. EventManager won't unresolve the group unless the event2 has a + # later timestamp than event1. + manager = EventManager(make_event(event_id="a" * 32, checksum="a" * 32, timestamp=ts)) + with self.tasks(): + event = manager.save(self.project.id) + + group = Group.objects.get(id=event.group_id) + group.status = GroupStatus.RESOLVED + group.substatus = None + group.save() + assert group.is_resolved() + + GroupOpenPeriod.objects.all().delete() + manager = EventManager( + make_event( + event_id="b" * 32, checksum="a" * 32, timestamp=before_now(minutes=3).isoformat() + ) + ) + event2 = manager.save(self.project.id) + assert event.group_id == event2.group_id + + group = Group.objects.get(id=group.id) + assert not group.is_resolved() + assert send_robust.called + + assert GroupOpenPeriod.objects.filter(group=group).count() == 0 + @mock.patch("sentry.event_manager.plugin_is_regression") def test_does_not_unresolve_group(self, plugin_is_regression: mock.MagicMock) -> None: # N.B. EventManager won't unresolve the group unless the event2 has a diff --git a/tests/sentry/issues/endpoints/test_organization_group_index.py b/tests/sentry/issues/endpoints/test_organization_group_index.py index 049814e532ffbb..61cf36271dc374 100644 --- a/tests/sentry/issues/endpoints/test_organization_group_index.py +++ b/tests/sentry/issues/endpoints/test_organization_group_index.py @@ -4689,6 +4689,46 @@ def test_set_resolved_in_current_release(self) -> None: assert open_period.date_ended == group.resolved_at assert open_period.resolution_activity == activity + @with_feature("organizations:issue-open-periods") + def test_set_resolved_in_current_release_without_open_period(self) -> None: + release = Release.objects.create(organization_id=self.project.organization_id, version="a") + release.add_project(self.project) + + group = self.create_group(status=GroupStatus.UNRESOLVED) + GroupOpenPeriod.objects.all().delete() + + self.login_as(user=self.user) + + response = self.get_success_response( + qs_params={"id": group.id}, status="resolved", statusDetails={"inRelease": "latest"} + ) + assert response.data["status"] == "resolved" + assert response.data["statusDetails"]["inRelease"] == release.version + assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id) + + group = Group.objects.get(id=group.id) + assert group.status == GroupStatus.RESOLVED + + resolution = GroupResolution.objects.get(group=group) + assert resolution.release == release + assert resolution.type == GroupResolution.Type.in_release + assert resolution.status == GroupResolution.Status.resolved + assert resolution.actor_id == self.user.id + + assert GroupSubscription.objects.filter( + user_id=self.user.id, group=group, is_active=True + ).exists() + + activity = Activity.objects.get( + group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value + ) + assert activity.data["version"] == release.version + assert GroupHistory.objects.filter( + group=group, status=GroupHistoryStatus.SET_RESOLVED_IN_RELEASE + ).exists() + + assert GroupOpenPeriod.objects.filter(group=group).count() == 0 + @with_feature("organizations:issue-open-periods") def test_set_resolved_in_explicit_release(self) -> None: release = Release.objects.create(organization_id=self.project.organization_id, version="a")