diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 2f9bd4ff5af1b7..2d3332e97c5a02 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -225,7 +225,6 @@ def update_groups( ) if user_options: self_assign_issue = user_options[0].value - if search_fn and not group_ids: try: cursor_result, _ = search_fn( @@ -300,6 +299,34 @@ def update_groups( res_type = GroupResolution.Type.in_next_release res_type_str = "in_next_release" res_status = GroupResolution.Status.pending + elif status_details.get("inUpcomingRelease"): + if len(projects) > 1: + return Response( + {"detail": "Cannot set resolved in upcoming release for multiple projects."}, + status=400, + ) + release = ( + status_details.get("inUpcomingRelease") + or Release.objects.filter( + projects=projects[0], organization_id=projects[0].organization_id + ) + .extra(select={"sort": "COALESCE(date_released, date_added)"}) + .order_by("-sort")[0] + ) + activity_type = ActivityType.SET_RESOLVED_IN_RELEASE.value + activity_data = {"version": ""} + + serialized_user = user_service.serialize_many( + filter=dict(user_ids=[user.id]), as_user=user + ) + new_status_details = { + "inUpcomingRelease": True, + } + if serialized_user: + new_status_details["actor"] = serialized_user[0] + res_type = GroupResolution.Type.in_upcoming_release + res_type_str = "in_upcoming_release" + res_status = GroupResolution.Status.pending elif status_details.get("inRelease"): # TODO(jess): We could update validation to check if release # applies to multiple projects, but I think we agreed to punt @@ -606,6 +633,7 @@ def update_groups( if res_type in ( GroupResolution.Type.in_next_release, GroupResolution.Type.in_release, + GroupResolution.Type.in_upcoming_release, ): result["activity"] = serialize( Activity.objects.get_activities_for_group( diff --git a/src/sentry/api/helpers/group_index/validators/status_details.py b/src/sentry/api/helpers/group_index/validators/status_details.py index ec08c4b7b0bb2b..283229e63369cb 100644 --- a/src/sentry/api/helpers/group_index/validators/status_details.py +++ b/src/sentry/api/helpers/group_index/validators/status_details.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from sentry import features from sentry.models.release import Release from . import InCommitValidator @@ -7,6 +8,7 @@ class StatusDetailsValidator(serializers.Serializer): inNextRelease = serializers.BooleanField() + inUpcomingRelease = serializers.BooleanField() inRelease = serializers.CharField() inCommit = InCommitValidator(required=False) ignoreDuration = serializers.IntegerField() @@ -54,3 +56,19 @@ def validate_inNextRelease(self, value: bool) -> "Release": raise serializers.ValidationError( "No release data present in the system to form a basis for 'Next Release'" ) + + def validate_inUpcomingRelease(self, value: bool) -> "Release": + project = self.context["project"] + + if not features.has("organizations:resolve-in-upcoming-release", project.organization): + raise serializers.ValidationError( + "Your organization does not have access to this feature." + ) + try: + return ( + Release.objects.filter(projects=project, organization_id=project.organization_id) + .extra(select={"sort": "COALESCE(date_released, date_added)"}) + .order_by("-sort")[0] + ) + except IndexError: + raise serializers.ValidationError("No release data present in the system.") diff --git a/src/sentry/models/groupresolution.py b/src/sentry/models/groupresolution.py index 4b9d7414de0c2c..1dff3f56a970fb 100644 --- a/src/sentry/models/groupresolution.py +++ b/src/sentry/models/groupresolution.py @@ -30,6 +30,7 @@ class GroupResolution(Model): class Type: in_release = 0 in_next_release = 1 + in_upcoming_release = 2 class Status: pending = 0 diff --git a/src/sentry/tasks/clear_expired_resolutions.py b/src/sentry/tasks/clear_expired_resolutions.py index 7ea31d3d105d6f..1b38186d718719 100644 --- a/src/sentry/tasks/clear_expired_resolutions.py +++ b/src/sentry/tasks/clear_expired_resolutions.py @@ -20,7 +20,7 @@ def clear_expired_resolutions(release_id): the system that any pending resolutions older than the given release can now be safely transitioned to resolved. - This is currently only used for ``in_next_release`` resolutions. + This is currently only used for ``in_next_release`` and ``in_upcoming_release`` resolutions. """ try: release = Release.objects.get(id=release_id) @@ -29,7 +29,9 @@ def clear_expired_resolutions(release_id): resolution_list = list( GroupResolution.objects.filter( - Q(type=GroupResolution.Type.in_next_release) | Q(type__isnull=True), + Q(type=GroupResolution.Type.in_next_release) + | Q(type__isnull=True) + | Q(type=GroupResolution.Type.in_upcoming_release), release__projects__in=[p.id for p in release.projects.all()], release__date_added__lt=release.date_added, status=GroupResolution.Status.pending, diff --git a/tests/sentry/issues/endpoints/test_organization_group_index.py b/tests/sentry/issues/endpoints/test_organization_group_index.py index 21952e9528f51c..f452229300f463 100644 --- a/tests/sentry/issues/endpoints/test_organization_group_index.py +++ b/tests/sentry/issues/endpoints/test_organization_group_index.py @@ -4954,6 +4954,22 @@ def test_update_priority_no_change(self) -> None: group=group2, type=ActivityType.SET_PRIORITY.value, user_id=self.user.id ).exists() + def test_resolved_in_upcoming_release_multiple_projects(self) -> None: + project_2 = self.create_project(slug="foo") + group1 = self.create_group(status=GroupStatus.UNRESOLVED) + group2 = self.create_group(status=GroupStatus.UNRESOLVED, project=project_2) + + self.login_as(user=self.user) + response = self.get_response( + qs_params={ + "id": [group1.id, group2.id], + "statd": "resolved", + "statusDetails": {"inUpcomingRelease": True}, + } + ) + + assert response.status_code == 400 + class GroupDeleteTest(APITestCase, SnubaTestCase): endpoint = "sentry-api-0-organization-group-index" diff --git a/tests/snuba/api/endpoints/test_project_group_index.py b/tests/snuba/api/endpoints/test_project_group_index.py index 31789e885ca99e..a4a35305fbf992 100644 --- a/tests/snuba/api/endpoints/test_project_group_index.py +++ b/tests/snuba/api/endpoints/test_project_group_index.py @@ -29,7 +29,7 @@ from sentry.models.release import Release from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, SnubaTestCase -from sentry.testutils.helpers import parse_link_header +from sentry.testutils.helpers import parse_link_header, with_feature from sentry.testutils.helpers.datetime import before_now, iso_format from sentry.testutils.silo import assume_test_silo_mode from sentry.types.activity import ActivityType @@ -791,6 +791,83 @@ def test_set_resolved_in_next_release_legacy(self): ) assert activity.data["version"] == "" + @with_feature("organizations:resolve-in-upcoming-release") + def test_set_resolved_in_upcoming_release(self): + release = Release.objects.create(organization_id=self.project.organization_id, version="a") + release.add_project(self.project) + + group = self.create_group(status=GroupStatus.UNRESOLVED) + + self.login_as(user=self.user) + + url = f"{self.path}?id={group.id}" + response = self.client.put( + url, + data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}}, + format="json", + ) + assert response.status_code == 200 + assert response.data["status"] == "resolved" + assert response.data["statusDetails"]["inUpcomingRelease"] + assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id) + assert "activity" in response.data + + 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_upcoming_release + assert resolution.status == GroupResolution.Status.pending + 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"] == "" + + def test_upcoming_release_flag_validation(self): + release = Release.objects.create(organization_id=self.project.organization_id, version="a") + release.add_project(self.project) + + group = self.create_group(status=GroupStatus.UNRESOLVED) + + self.login_as(user=self.user) + + url = f"{self.path}?id={group.id}" + response = self.client.put( + url, + data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}}, + format="json", + ) + assert response.status_code == 400 + assert ( + response.data["statusDetails"]["inUpcomingRelease"][0] + == "Your organization does not have access to this feature." + ) + + @with_feature("organizations:resolve-in-upcoming-release") + def test_upcoming_release_release_validation(self): + group = self.create_group(status=GroupStatus.UNRESOLVED) + + self.login_as(user=self.user) + + url = f"{self.path}?id={group.id}" + response = self.client.put( + url, + data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}}, + format="json", + ) + assert response.status_code == 400 + assert ( + response.data["statusDetails"]["inUpcomingRelease"][0] + == "No release data present in the system." + ) + def test_set_resolved_in_explicit_commit_unreleased(self): repo = self.create_repo(project=self.project, name=self.project.name) commit = self.create_commit(project=self.project, repo=repo)