diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 359c3cf275dce5..b97aa2146dd59d 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -76,6 +76,7 @@ class ResolutionParams(TypedDict): status: int | None actor_id: int | None current_release_version: NotRequired[str] + future_release_version: NotRequired[str] def handle_discard( @@ -221,7 +222,7 @@ def update_groups( acting_user=acting_user, project_lookup=project_lookup, ) - if status in ("resolved", "resolvedInNextRelease"): + if status in ("resolved", "resolvedInNextRelease", "resolvedInFutureRelease"): try: result, res_type = handle_resolve_in_release( status, @@ -356,6 +357,7 @@ def handle_resolve_in_release( ) -> tuple[dict[str, Any], int | None]: res_type = None release = None + future_release_version = None commit = None self_assign_issue = "0" new_status_details = {} @@ -389,6 +391,37 @@ def handle_resolve_in_release( res_type = GroupResolution.Type.in_next_release res_type_str = "in_next_release" res_status = GroupResolution.Status.pending + elif status == "resolvedInFutureRelease" or status_details.get("inFutureRelease"): + if len(projects) > 1: + raise MultipleProjectsError() + + release = status_details.get("inFutureRelease") + # Release to resolve by may not exist yet, so just use a random placeholder + # TODO: THIS CAN NEVER BE NONE -- what if no releases exist yet? + release_placeholder = release or get_release_to_resolve_by(projects[0]) + # Get the original version string stored by the validator + future_release_version = status_details.get("_future_release_version") + + activity_type = ActivityType.SET_RESOLVED_IN_RELEASE.value + + if release: + # Release exists, so just resolve in_release + new_status_details["inRelease"] = release.version + res_type = GroupResolution.Type.in_release + res_type_str = "in_release" + res_status = GroupResolution.Status.resolved + activity_data = {"version": release.version} + else: + new_status_details["inFutureRelease"] = future_release_version + res_type = GroupResolution.Type.in_future_release + res_type_str = "in_future_release" + res_status = GroupResolution.Status.pending + activity_data = {"version": ""} + # Set activity_data["future_release_version"] in process_group_resolution + + # Pass placeholder release to process_group_resolution + release = release_placeholder + 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 @@ -452,6 +485,7 @@ def handle_resolve_in_release( group, group_list, release, + future_release_version, commit, res_type, res_status, @@ -484,6 +518,7 @@ def process_group_resolution( group: Group, group_list: Sequence[Group], release: Release | None, + future_release_version: str | None, commit: Commit | None, res_type: int | None, res_status: int | None, @@ -592,6 +627,12 @@ def process_group_resolution( # fall back to our current model ... + elif res_type == GroupResolution.Type.in_future_release and future_release_version: + resolution_params.update({"future_release_version": future_release_version}) + + # Activity status should look like: "... resolved in version >future_release_version" + activity_data.update({"future_release_version": future_release_version}) + resolution, created = GroupResolution.objects.get_or_create( group=group, defaults=resolution_params ) @@ -754,7 +795,11 @@ def prepare_response( # what performance impact this might have & this possibly should be moved else where try: if len(group_list) == 1: - if res_type in (GroupResolution.Type.in_next_release, GroupResolution.Type.in_release): + if res_type in ( + GroupResolution.Type.in_next_release, + GroupResolution.Type.in_release, + GroupResolution.Type.in_future_release, + ): result["activity"] = serialize( Activity.objects.get_activities_for_group( group=group_list[0], num=ACTIVITIES_COUNT 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 0c9ea2916a4a3f..ee57f825e7cd48 100644 --- a/src/sentry/api/helpers/group_index/validators/status_details.py +++ b/src/sentry/api/helpers/group_index/validators/status_details.py @@ -1,13 +1,15 @@ -from typing import NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers +from sentry import features from sentry.api.helpers.group_index.validators.in_commit import InCommitResult, InCommitValidator from sentry.models.release import Release class StatusDetailsResult(TypedDict): + inFutureRelease: NotRequired[bool] inNextRelease: NotRequired[bool] inRelease: NotRequired[str] inCommit: NotRequired[InCommitResult] @@ -20,6 +22,12 @@ class StatusDetailsResult(TypedDict): @extend_schema_serializer() class StatusDetailsValidator(serializers.Serializer[StatusDetailsResult]): + inFutureRelease = serializers.CharField( + help_text=( + "The version of the semver release that the issue should be resolved in." + "This release can be a future release that doesn't exist yet." + ) + ) inNextRelease = serializers.BooleanField( help_text="If true, marks the issue as resolved in the next release." ) @@ -87,3 +95,56 @@ 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_inFutureRelease(self, value: str) -> "Release | None": + project = self.context["project"] + + if not features.has("organizations:resolve-in-future-release", project.organization): + raise serializers.ValidationError( + "Your organization does not have access to this feature." + ) + + if not Release.is_valid_version(value): + raise serializers.ValidationError( + "Invalid release version format. Please use semver format: package@major.minor.patch[-prerelease][+build]." + ) + + try: + # release doesn't have to be semver if it exists + # because we'll just use the resolveInRelease logic + release = Release.objects.get( + projects=project, organization_id=project.organization_id, version=value + ) + except Release.DoesNotExist: + # release must be semver if it doesn't exist + if not Release.is_semver_version(value): + raise serializers.ValidationError( + "Invalid semver format. Please use format: package@major.minor.patch[-prerelease][+build]" + ) + release = None + + return release + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + """ + Cross-field validation hook called by DRF after individual field validation. + """ + return self._preserve_future_release_version(attrs) + + def _preserve_future_release_version(self, attrs: dict[str, Any]) -> dict[str, Any]: + """ + Store the original future release version string for inFutureRelease since the validator + transforms it to a Release object or None, but we need the version string for + process_group_resolution. + """ + if "inFutureRelease" in attrs: + initial_data = getattr(self, "initial_data", {}) + # If this is a nested serializer, try to get the data from the parent's initial_data + if not initial_data and hasattr(self, "parent"): + parent_initial_data = getattr(self.parent, "initial_data", {}) + initial_data = parent_initial_data.get("statusDetails", {}) + + future_release_version = initial_data.get("inFutureRelease") + if future_release_version: + attrs["_future_release_version"] = future_release_version + return attrs diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py index 6001198562280e..469743a0473399 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -89,6 +89,7 @@ class GroupStatusDetailsResponseOptional(TypedDict, total=False): actor: UserSerializerResponse inNextRelease: bool inRelease: str + inFutureRelease: str inCommit: str pendingEvents: int info: Any @@ -472,11 +473,13 @@ def _get_status(self, attrs: Mapping[str, Any], obj: Group): if status == GroupStatus.RESOLVED: status_label = "resolved" if attrs["resolution_type"] == "release": - res_type, res_version, _ = attrs["resolution"] + res_type, res_version, future_release_version, _ = attrs["resolution"] if res_type in (GroupResolution.Type.in_next_release, None): status_details["inNextRelease"] = True elif res_type == GroupResolution.Type.in_release: status_details["inRelease"] = res_version + elif res_type == GroupResolution.Type.in_future_release: + status_details["inFutureRelease"] = future_release_version status_details["actor"] = attrs["resolution_actor"] elif attrs["resolution_type"] == "commit": status_details["inCommit"] = attrs["resolution"] @@ -659,7 +662,7 @@ def _resolve_resolutions( _release_resolutions = { i[0]: i[1:] for i in GroupResolution.objects.filter(group__in=resolved_groups).values_list( - "group", "type", "release__version", "actor_id" + "group", "type", "release__version", "future_release_version", "actor_id" ) } diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 9f0a8b2633d9e3..e2d4f20d69dcb6 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -337,6 +337,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:replay-ai-summaries-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE) # Enable version 2 of release serializer manager.add("organizations:releases-serializer-v2", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable resolve in future, possibly nonexistent release + manager.add("organizations:resolve-in-future-release", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable version 2 of reprocessing (completely distinct from v1) manager.add("organizations:reprocessing-v2", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable issue resolve in current semver release diff --git a/src/sentry/models/group.py b/src/sentry/models/group.py index 17aa08704a040b..71e5331f4ccaa5 100644 --- a/src/sentry/models/group.py +++ b/src/sentry/models/group.py @@ -214,6 +214,7 @@ class GroupStatus: "unresolved": GroupStatus.UNRESOLVED, "ignored": GroupStatus.IGNORED, "resolvedInNextRelease": GroupStatus.UNRESOLVED, + "resolvedInFutureRelease": GroupStatus.UNRESOLVED, # TODO(dcramer): remove in 9.0 "muted": GroupStatus.IGNORED, } diff --git a/src/sentry/models/groupresolution.py b/src/sentry/models/groupresolution.py index 934cd488d16c9f..e69a8617deaabc 100644 --- a/src/sentry/models/groupresolution.py +++ b/src/sentry/models/groupresolution.py @@ -6,6 +6,7 @@ from sentry_relay.processing import compare_version as compare_version_relay from sentry_relay.processing import parse_release +from sentry import features from sentry.backup.scopes import RelocationScope from sentry.db.models import ( BoundedPositiveIntegerField, @@ -30,6 +31,7 @@ class GroupResolution(Model): class Type: in_release = 0 in_next_release = 1 + in_future_release = 2 class Status: pending = 0 @@ -46,7 +48,11 @@ class Status: # user chooses "resolve in future release" future_release_version = models.CharField(max_length=DB_VERSION_LENGTH, null=True, blank=True) type = BoundedPositiveIntegerField( - choices=((Type.in_next_release, "in_next_release"), (Type.in_release, "in_release")), + choices=( + (Type.in_next_release, "in_next_release"), + (Type.in_release, "in_release"), + (Type.in_future_release, "in_future_release"), + ), null=True, ) actor_id = BoundedPositiveIntegerField(null=True) @@ -90,6 +96,7 @@ def compare_release_dates_for_in_next_release(res_release, res_release_datetime, res_release_version, res_release_datetime, current_release_version, + future_release_version, ) = ( cls.objects.filter(group=group) .select_related("release") @@ -99,6 +106,7 @@ def compare_release_dates_for_in_next_release(res_release, res_release_datetime, "release__version", "release__date_added", "current_release_version", + "future_release_version", )[0] ) except IndexError: @@ -149,10 +157,26 @@ def compare_release_dates_for_in_next_release(res_release, res_release_datetime, except Release.DoesNotExist: ... + elif ( + future_release_version + and Release.is_semver_version(release.version) + and Release.is_semver_version(future_release_version) + and features.has("organizations:resolve-in-future-release", group.organization) + ): + # we have a regression if future_release_version <= given_release.version + # if future_release_version == given_release.version => 0 # regression + # if future_release_version < given_release.version => -1 # regression + # if future_release_version > given_release.version => 1 + future_release_raw = parse_release(future_release_version, json_loads=orjson.loads).get( + "version_raw" + ) + release_raw = parse_release(release.version, json_loads=orjson.loads).get("version_raw") + return compare_version_relay(future_release_raw, release_raw) > 0 + # We still fallback to the older model if either current_release_version was not set ( # i.e. In all resolved cases except for Resolved in Next Release) or if for whatever # reason the semver/date checks fail (which should not happen!) - if res_type in (None, cls.Type.in_next_release): + if res_type in (None, cls.Type.in_next_release, cls.Type.in_future_release): # Add metric here to ensure that this code branch ever runs given that # clear_expired_resolutions changes the type to `in_release` once a Release instance # is created diff --git a/src/sentry/tasks/clear_expired_resolutions.py b/src/sentry/tasks/clear_expired_resolutions.py index 96b1e4dc801ab2..94f76cafc51329 100644 --- a/src/sentry/tasks/clear_expired_resolutions.py +++ b/src/sentry/tasks/clear_expired_resolutions.py @@ -1,5 +1,9 @@ +import orjson from django.db.models import Q +from sentry_relay.processing import compare_version as compare_version_relay +from sentry_relay.processing import parse_release +from sentry import features from sentry.models.activity import Activity from sentry.models.groupresolution import GroupResolution from sentry.models.release import Release @@ -9,25 +13,7 @@ from sentry.types.activity import ActivityType -@instrumented_task( - name="sentry.tasks.clear_expired_resolutions", - namespace=issues_tasks, - processing_deadline_duration=15, - silo_mode=SiloMode.REGION, -) -def clear_expired_resolutions(release_id): - """ - This should be fired when ``release_id`` is created, and will indicate to - 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`` resolution. - """ - try: - release = Release.objects.get(id=release_id) - except Release.DoesNotExist: - return - +def clear_next_release_resolutions(release): resolution_list = list( GroupResolution.objects.filter( Q(type=GroupResolution.Type.in_next_release) | Q(type__isnull=True), @@ -58,3 +44,87 @@ def clear_expired_resolutions(release_id): # TODO: Do we need to write a `GroupHistory` row here? activity.update(data={"version": release.version}) + + +def clear_future_release_resolutions(release): + """ + Clear group resolutions of type `in_future_release` where: + 1. The organization the release belongs to has the "resolve-in-future-release" feature flag enabled + 2. The future_release_version is <= the newly created release version (using semver comparison) + 3. The resolution is still pending + 4. The resolution belongs to the same organization as the release + """ + if not features.has( + "organizations:resolve-in-future-release", release.organization + ) or not Release.is_semver_version(release.version): + return + + release_parsed = parse_release(release.version, json_loads=orjson.loads).get("version_raw") + + resolution_candidates = GroupResolution.objects.filter( + type=GroupResolution.Type.in_future_release, + status=GroupResolution.Status.pending, + group__project__organization=release.organization, + future_release_version__isnull=False, + ) + + resolution_list = [] + for resolution in resolution_candidates: + if not Release.is_semver_version(resolution.future_release_version): + continue + + # If release.version >= future_release_version, clear the resolution + try: + future_parsed = parse_release( + resolution.future_release_version, json_loads=orjson.loads + ).get("version_raw") + + if compare_version_relay(release_parsed, future_parsed) >= 0: + resolution_list.append(resolution) + except Exception: + continue + + if not resolution_list: + return + + GroupResolution.objects.filter(id__in=[r.id for r in resolution_list]).update( + release=release, + type=GroupResolution.Type.in_release, + status=GroupResolution.Status.resolved, + ) + + for resolution in resolution_list: + try: + activity = Activity.objects.filter( + group=resolution.group_id, + type=ActivityType.SET_RESOLVED_IN_RELEASE.value, + ident=resolution.id, + ).order_by("-datetime")[0] + except IndexError: + continue + + # TODO: Do we need to write a `GroupHistory` row here? + activity.update(data={"version": release.version}) + + +@instrumented_task( + name="sentry.tasks.clear_expired_resolutions", + namespace=issues_tasks, + processing_deadline_duration=15, + silo_mode=SiloMode.REGION, +) +def clear_expired_resolutions(release_id): + """ + This should be fired when ``release_id`` is created, and will indicate to + 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`` and ``in_future_release`` resolutions. + """ + try: + release = Release.objects.get(id=release_id) + except Release.DoesNotExist: + return + + clear_next_release_resolutions(release) + clear_future_release_resolutions(release) diff --git a/tests/sentry/api/helpers/group_index/validators/test_status_details.py b/tests/sentry/api/helpers/group_index/validators/test_status_details.py new file mode 100644 index 00000000000000..a77773614b5241 --- /dev/null +++ b/tests/sentry/api/helpers/group_index/validators/test_status_details.py @@ -0,0 +1,110 @@ +from rest_framework.exceptions import ErrorDetail + +from sentry.api.helpers.group_index.validators.group import GroupValidator +from sentry.api.helpers.group_index.validators.status_details import StatusDetailsValidator +from sentry.models.release import Release +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers import with_feature + + +class StatusDetailsValidatorTest(TestCase): + def setUp(self): + super().setUp() + self.project = self.create_project() + self.organization = self.project.organization + + def get_validator(self, data=None, context=None): + if context is None: + context = {"project": self.project} + validator = StatusDetailsValidator(data=data or {}, context=context, partial=True) + return validator + + def test_validate_in_future_release_without_feature_flag(self): + """Test that validation fails when feature flag is not enabled.""" + validator = self.get_validator(data={"inFutureRelease": "package@1.0.0"}) + assert not validator.is_valid() + assert validator.errors.get("inFutureRelease") == [ + ErrorDetail( + string="Your organization does not have access to this feature.", code="invalid" + ) + ] + + @with_feature("organizations:resolve-in-future-release") + def test_validate_in_future_release_invalid_version_format(self): + """Test that validation fails for invalid version formats.""" + validator = self.get_validator(data={"inFutureRelease": "version\twith\ttabs"}) + assert not validator.is_valid() + assert validator.errors.get("inFutureRelease") == [ + ErrorDetail( + string="Invalid release version format. Please use semver format: package@major.minor.patch[-prerelease][+build].", + code="invalid", + ) + ] + + @with_feature("organizations:resolve-in-future-release") + def test_validate_in_future_release_invalid_semver_format(self): + """Test that validation fails for invalid semver formats.""" + validator = self.get_validator(data={"inFutureRelease": "package@invalid.semver"}) + assert not validator.is_valid() + assert validator.errors.get("inFutureRelease") == [ + ErrorDetail( + string="Invalid semver format. Please use format: package@major.minor.patch[-prerelease][+build]", + code="invalid", + ) + ] + + def _validate_in_future_release_existing_release_helper(self, expected_release: Release): + """Helper method to validate existing release.""" + validator = self.get_validator(data={"inFutureRelease": expected_release.version}) + assert validator.is_valid() + assert validator.validated_data["inFutureRelease"] == expected_release + assert validator.validated_data["_future_release_version"] == expected_release.version + + @with_feature("organizations:resolve-in-future-release") + def test_validate_in_future_release_existing_release(self): + """Test that validation passes when release exists for both semver and non-semver versions.""" + expected_release = self.create_release(project=self.project, version="package@1.0.0") + self._validate_in_future_release_existing_release_helper(expected_release) + + expected_release = self.create_release(project=self.project, version="non-semver-version") + self._validate_in_future_release_existing_release_helper(expected_release) + + @with_feature("organizations:resolve-in-future-release") + def test_validate_in_future_release_nonexistent_release_valid_semver(self): + """Test that validation passes when release doesn't exist but is valid semver.""" + validator = self.get_validator(data={"inFutureRelease": "package@1.0.0"}) + assert validator.is_valid() + assert validator.validated_data["inFutureRelease"] is None + assert validator.validated_data["_future_release_version"] == "package@1.0.0" + + @with_feature("organizations:resolve-in-future-release") + def test_validate_in_future_release_nonexistent_release_invalid_semver(self): + """Test that validation fails when release doesn't exist and is not valid semver.""" + validator = self.get_validator(data={"inFutureRelease": "non-semver-version"}) + assert not validator.is_valid() + assert validator.errors.get("inFutureRelease") == [ + ErrorDetail( + string="Invalid semver format. Please use format: package@major.minor.patch[-prerelease][+build]", + code="invalid", + ) + ] + + @with_feature("organizations:resolve-in-future-release") + def test_validate_in_future_release_as_nested_serializer(self): + """ + Test that _future_release_version is preserved when StatusDetailsValidator + is used as a nested serializer inside GroupValidator. + """ + parent_validator = GroupValidator( + data={ + "status": "resolvedInFutureRelease", + "statusDetails": {"inFutureRelease": "package@2.0.0"}, + }, + partial=True, + context={"project": self.project, "organization": self.organization}, + ) + + assert parent_validator.is_valid() + status_details = parent_validator.validated_data.get("statusDetails", {}) + assert status_details["inFutureRelease"] is None + assert status_details["_future_release_version"] == "package@2.0.0" diff --git a/tests/sentry/issues/endpoints/test_group_details.py b/tests/sentry/issues/endpoints/test_group_details.py index c3bfa468582be1..7c49eac20fab78 100644 --- a/tests/sentry/issues/endpoints/test_group_details.py +++ b/tests/sentry/issues/endpoints/test_group_details.py @@ -28,6 +28,7 @@ from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.datetime import freeze_time +from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode from sentry.testutils.skips import requires_snuba @@ -456,6 +457,126 @@ def test_resolved_in_next_release_no_release(self) -> None: assert not GroupResolution.objects.filter(group=group).exists() assert response.data["statusDetails"] == {} + @with_feature("organizations:resolve-in-future-release") + def test_resolved_in_future_existing_release(self) -> None: + self.login_as(user=self.user) + + future_release = self.create_release(project=self.project, version="package@1.0.0") + group = self.create_group(status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING) + + response = self.client.put( + path=f"/api/0/issues/{group.id}/", + data={ + "status": "resolvedInFutureRelease", + "statusDetails": {"inFutureRelease": future_release.version}, + }, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.data["status"] == "resolved" + assert response.data["statusDetails"]["inRelease"] == future_release.version + assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id) + + group = Group.objects.get(id=group.id) + assert group.status == GroupStatus.RESOLVED + + # GroupResolution should be resolved in_release because the release already exists + resolution = GroupResolution.objects.get(group=group) + assert resolution.release == future_release + assert resolution.type == GroupResolution.Type.in_release + assert resolution.status == GroupResolution.Status.resolved + assert resolution.future_release_version is None + 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"] == future_release.version + + @with_feature("organizations:resolve-in-future-release") + def test_resolved_in_future_nonexistent_release(self) -> None: + self.login_as(user=self.user) + + project = self.create_project_with_releases() + releases = [ + Release.get_or_create(version="package@1.0+0", project=project), + Release.get_or_create(version="package@2.0+0", project=project), + Release.get_or_create(version="package@1.0+1", project=project), + ] + placeholder_release = releases[1] + + nonexistent_future_release_version = "package@3.0.0" + group = self.create_group( + project=project, status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING + ) + + response = self.client.put( + path=f"/api/0/issues/{group.id}/", + data={ + "status": "resolvedInFutureRelease", + "statusDetails": {"inFutureRelease": nonexistent_future_release_version}, + }, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.data["status"] == "resolved" + assert ( + response.data["statusDetails"]["inFutureRelease"] == nonexistent_future_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) + # since GroupResolution.release is non-nullable, we set a placeholder release, + # which is determined by get_release_to_resolve_by: + assert resolution.release == placeholder_release + assert resolution.type == GroupResolution.Type.in_future_release + assert resolution.status == GroupResolution.Status.pending + assert resolution.future_release_version == nonexistent_future_release_version + 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["future_release_version"] == nonexistent_future_release_version + + def test_resolved_in_future_release_without_feature_flag(self) -> None: + """Test that resolving in future release fails when feature flag is disabled.""" + self.login_as(user=self.user) + self.create_release(project=self.project, version="2.0.0") + group = self.create_group(status=GroupStatus.UNRESOLVED) + + response = self.client.put( + path=f"/api/0/issues/{group.id}/", + data={ + "status": "resolvedInFutureRelease", + "statusDetails": {"inFutureRelease": "2.0.0"}, + }, + format="json", + ) + + assert response.status_code == 400, response.content + assert "Your organization does not have access to this feature." in str(response.data) + + # Group should remain unresolved + group = Group.objects.get(id=group.id) + assert group.status == GroupStatus.UNRESOLVED + + # No GroupResolution should be created + assert not GroupResolution.objects.filter(group=group).exists() + def test_snooze_duration(self) -> None: group = self.create_group(status=GroupStatus.RESOLVED) diff --git a/tests/sentry/models/test_groupresolution.py b/tests/sentry/models/test_groupresolution.py index 39dac4983d89e9..e0c1902e1599f6 100644 --- a/tests/sentry/models/test_groupresolution.py +++ b/tests/sentry/models/test_groupresolution.py @@ -4,6 +4,7 @@ from sentry.models.groupresolution import GroupResolution from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature class GroupResolutionTest(TestCase): @@ -199,6 +200,99 @@ def test_no_release_with_resolution(self) -> None: def test_no_release_with_no_resolution(self) -> None: assert not GroupResolution.has_resolution(self.group, None) + @with_feature("organizations:resolve-in-future-release") + def test_in_future_release_with_semver_and_newer_release(self) -> None: + """Test that release newer than self.new_semver_release + has no resolution with group resolved in self.new_semver_release.""" + newer_semver_release = self.create_release(version="foo_package@2.1") + + GroupResolution.objects.create( + release=self.old_semver_release, + group=self.group, + type=GroupResolution.Type.in_future_release, + future_release_version=self.new_semver_release.version, + ) + + assert not GroupResolution.has_resolution(self.group, newer_semver_release) + + @with_feature("organizations:resolve-in-future-release") + def test_in_future_release_with_semver_and_same_release(self) -> None: + """Test that release same as self.new_semver_release + has no resolution with group resolved in self.new_semver_release.""" + GroupResolution.objects.create( + release=self.old_semver_release, + group=self.group, + type=GroupResolution.Type.in_future_release, + future_release_version=self.new_semver_release.version, + ) + + assert not GroupResolution.has_resolution(self.group, self.new_semver_release) + + @with_feature("organizations:resolve-in-future-release") + def test_in_future_release_with_semver_and_older_release(self) -> None: + """Test that release older than self.new_semver_release + has resolution with group resolved in self.new_semver_release.""" + older_semver_release = self.create_release(version="foo_package@1.9") + + GroupResolution.objects.create( + release=self.old_semver_release, + group=self.group, + type=GroupResolution.Type.in_future_release, + future_release_version=self.new_semver_release.version, + ) + + assert GroupResolution.has_resolution(self.group, older_semver_release) + + @with_feature("organizations:resolve-in-future-release") + def test_in_future_release_non_semver(self) -> None: + """Test that non-semver releases fall back to the older date-based comparison model.""" + # Newer semver but older date + release_v2_older_date = self.create_release( + version="bar_package@3.0", date_added=timezone.now() - timedelta(minutes=60) + ) + # Older semver but newer date + release_v1_newer_date = self.create_release( + version="bar_package@2.5", date_added=timezone.now() - timedelta(minutes=30) + ) + + GroupResolution.objects.create( + release=release_v1_newer_date, + group=self.group, + type=GroupResolution.Type.in_future_release, + future_release_version="non-semver-version", + ) + + # By date-based logic, 3.0 released before 2.5 -> resolution + assert GroupResolution.has_resolution(self.group, release_v2_older_date) + + # The resolution release itself should have resolution + assert GroupResolution.has_resolution(self.group, release_v1_newer_date) + + def test_in_future_release_no_feature_flag(self) -> None: + """Test that without feature flag, fall back to the older date-based comparison model.""" + # Newer semver but older date + release_v3_older_date = self.create_release( + version="baz_package@3.0", date_added=timezone.now() - timedelta(minutes=60) + ) + # Older semver but newer date + release_v2_newer_date = self.create_release( + version="baz_package@2.5", date_added=timezone.now() - timedelta(minutes=30) + ) + + GroupResolution.objects.create( + release=release_v2_newer_date, + group=self.group, + type=GroupResolution.Type.in_future_release, + future_release_version="baz_package@2.8", + ) + + # By semver logic, 3.0 > 2.8 -> regression + # By date-based logic, 3.0 released before 2.5 -> resolution + assert GroupResolution.has_resolution(self.group, release_v3_older_date) + + # The resolution release itself should have resolution + assert GroupResolution.has_resolution(self.group, release_v2_newer_date) + def test_all_resolutions_are_implemented(self) -> None: resolution_types = [ attr for attr in vars(GroupResolution.Type) if not attr.startswith("__") diff --git a/tests/sentry/tasks/test_clear_expired_resolutions.py b/tests/sentry/tasks/test_clear_expired_resolutions.py index c34d77b0732ff0..7cf9e9222cc3b7 100644 --- a/tests/sentry/tasks/test_clear_expired_resolutions.py +++ b/tests/sentry/tasks/test_clear_expired_resolutions.py @@ -8,6 +8,7 @@ from sentry.models.release import Release from sentry.tasks.clear_expired_resolutions import clear_expired_resolutions from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.types.activity import ActivityType @@ -75,3 +76,210 @@ def test_simple(self) -> None: activity2 = Activity.objects.get(id=activity2.id) assert activity2.data["version"] == "" + + @with_feature("organizations:resolve-in-future-release") + def test_in_future_release_resolutions(self) -> None: + """Test that in_future_release resolutions are properly cleared when a new release is created.""" + project = self.create_project() + + # Create group in release 1.0.0 resolved in future release 2.0.0 + old_release = self.create_release( + project=project, version="package@1.0.0", date_added=timezone.now() + ) + group = self.create_group( + project=project, status=GroupStatus.RESOLVED, active_at=timezone.now() + ) + resolution = GroupResolution.objects.create( + group=group, + release=old_release, + type=GroupResolution.Type.in_future_release, + future_release_version="package@2.0.0", + status=GroupResolution.Status.pending, + ) + activity = self.create_group_activity( + group=group, + type=ActivityType.SET_RESOLVED_IN_RELEASE.value, + ident=resolution.id, + data={"version": ""}, + ) + + # Create the 2.0.0 release that should trigger resolution clearing + new_release = self.create_release( + project=project, + version="package@2.0.0", + date_added=timezone.now() + timedelta(minutes=1), + ) + + clear_expired_resolutions(new_release.id) + + updated_resolution = GroupResolution.objects.get(id=resolution.id) + assert updated_resolution.status == GroupResolution.Status.resolved + assert updated_resolution.release == new_release + assert updated_resolution.type == GroupResolution.Type.in_release + + updated_activity = Activity.objects.get(id=activity.id) + assert updated_activity.data["version"] == new_release.version + + def test_in_future_release_without_feature_flag(self) -> None: + """Test that in_future_release resolutions are NOT cleared when feature flag is disabled.""" + project = self.create_project() + + # Create group in release 1.0.0 resolved in future release 2.0.0 + old_release = self.create_release( + project=project, version="package@1.0.0", date_added=timezone.now() + ) + group = self.create_group( + project=project, status=GroupStatus.RESOLVED, active_at=timezone.now() + ) + resolution = GroupResolution.objects.create( + group=group, + release=old_release, + type=GroupResolution.Type.in_future_release, + future_release_version="package@2.0.0", + status=GroupResolution.Status.pending, + ) + + # Create the 2.0.0 release that should trigger resolution clearing + new_release = self.create_release( + project=project, + version="package@2.0.0", + date_added=timezone.now() + timedelta(minutes=1), + ) + + clear_expired_resolutions(new_release.id) + + # Resolution should remain unchanged because feature flag is disabled + updated_resolution = GroupResolution.objects.get(id=resolution.id) + assert updated_resolution.status == GroupResolution.Status.pending + assert updated_resolution.release == old_release + assert updated_resolution.type == GroupResolution.Type.in_future_release + + @with_feature("organizations:resolve-in-future-release") + def test_in_future_release_different_organization(self) -> None: + """Test that in_future_release resolutions are NOT cleared for different organizations.""" + organization1 = self.create_organization() + organization2 = self.create_organization() + project1 = self.create_project(organization=organization1) + project2 = self.create_project(organization=organization2) + + # Create group in project1 resolved in future release 2.0.0 + old_release = self.create_release( + project=project1, version="package@1.0.0", date_added=timezone.now() + ) + group = self.create_group( + project=project1, status=GroupStatus.RESOLVED, active_at=timezone.now() + ) + resolution = GroupResolution.objects.create( + group=group, + release=old_release, + type=GroupResolution.Type.in_future_release, + future_release_version="package@2.0.0", + status=GroupResolution.Status.pending, + ) + + # Create the 2.0.0 release in project2 (different organization) + new_release = self.create_release( + project=project2, + version="package@2.0.0", + date_added=timezone.now() + timedelta(minutes=1), + ) + + clear_expired_resolutions(new_release.id) + + # Resolution should remain unchanged + updated_resolution = GroupResolution.objects.get(id=resolution.id) + assert updated_resolution.status == GroupResolution.Status.pending + assert updated_resolution.release == old_release + assert updated_resolution.type == GroupResolution.Type.in_future_release + + @with_feature("organizations:resolve-in-future-release") + def test_in_future_release_multiple_groups(self) -> None: + """Test that multiple groups with future_release_version <= new_release.version are all cleared.""" + project = self.create_project() + + old_release = self.create_release(project=project, version="package@1.0.0") + + # group 1: resolved in future release 1.0.0 + group1 = self.create_group( + project=project, status=GroupStatus.RESOLVED, active_at=timezone.now() + ) + resolution1 = GroupResolution.objects.create( + group=group1, + release=old_release, + type=GroupResolution.Type.in_future_release, + future_release_version="package@1.0.0", + status=GroupResolution.Status.pending, + ) + activity1 = self.create_group_activity( + group=group1, + type=ActivityType.SET_RESOLVED_IN_RELEASE.value, + ident=resolution1.id, + data={"version": ""}, + ) + + # group 2: resolved in future release 2.0.0 + group2 = self.create_group( + project=project, status=GroupStatus.RESOLVED, active_at=timezone.now() + ) + resolution2 = GroupResolution.objects.create( + group=group2, + release=old_release, + type=GroupResolution.Type.in_future_release, + future_release_version="package@2.0.0", + status=GroupResolution.Status.pending, + ) + activity2 = self.create_group_activity( + group=group2, + type=ActivityType.SET_RESOLVED_IN_RELEASE.value, + ident=resolution2.id, + data={"version": ""}, + ) + + # group 3: resolved in future release 3.0.0 + group3 = self.create_group( + project=project, status=GroupStatus.RESOLVED, active_at=timezone.now() + ) + resolution3 = GroupResolution.objects.create( + group=group3, + release=old_release, + type=GroupResolution.Type.in_future_release, + future_release_version="package@3.0.0", + status=GroupResolution.Status.pending, + ) + activity3 = self.create_group_activity( + group=group3, + type=ActivityType.SET_RESOLVED_IN_RELEASE.value, + ident=resolution3.id, + data={"version": ""}, + ) + + # Create the 2.0.0 release + new_release = self.create_release( + project=project, + version="package@2.0.0", + date_added=timezone.now() + timedelta(minutes=1), + ) + + clear_expired_resolutions(new_release.id) + + # Only groups 1 and 2 should be updated + updated_resolution1 = GroupResolution.objects.get(id=resolution1.id) + assert updated_resolution1.status == GroupResolution.Status.resolved + assert updated_resolution1.release == new_release + assert updated_resolution1.type == GroupResolution.Type.in_release + updated_activity1 = Activity.objects.get(id=activity1.id) + assert updated_activity1.data["version"] == new_release.version + + updated_resolution2 = GroupResolution.objects.get(id=resolution2.id) + assert updated_resolution2.status == GroupResolution.Status.resolved + assert updated_resolution2.release == new_release + assert updated_resolution2.type == GroupResolution.Type.in_release + updated_activity2 = Activity.objects.get(id=activity2.id) + assert updated_activity2.data["version"] == new_release.version + + updated_resolution3 = GroupResolution.objects.get(id=resolution3.id) + assert updated_resolution3.status == GroupResolution.Status.pending + assert updated_resolution3.release == old_release + assert updated_resolution3.type == GroupResolution.Type.in_future_release + updated_activity3 = Activity.objects.get(id=activity3.id) + assert updated_activity3.data["version"] == ""