diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index bde8654f36301d..19a354bd28597d 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,9 +381,19 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + queryset = queryset.annotate_prerelease_column() + if order_by_build_code: + queryset = queryset.annotate_build_code_column() + + semver_cols = ( + Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + ) + order_by = [F(col).desc(nulls_last=True) for col in semver_cols] - order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to # make this work as expected. @@ -553,9 +563,19 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + queryset = queryset.annotate_prerelease_column() + if order_by_build_code: + queryset = queryset.annotate_build_code_column() + + semver_cols = ( + Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + ) + order_by = [F(col).desc(nulls_last=True) for col in semver_cols] - order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to # make this work as expected. diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 632ccd9d79cfd9..5f0c7995a24129 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -853,12 +853,22 @@ def greatest_semver_release(project: Project) -> Release | None: def get_semver_releases(project: Project) -> QuerySet[Release]: - return ( + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", project.organization + ) + + semver_cols = ( + Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + ) + + qs = ( Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() - .order_by(*[f"-{col}" for col in Release.SEMVER_COLS]) ) + if order_by_build_code: + qs = qs.annotate_build_code_column() + return qs.order_by(*[f"-{col}" for col in semver_cols]) def handle_is_subscribed( diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 3a9a4692bec6eb..f639e51dfab061 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -367,6 +367,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:reprocessing-v2", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable issue resolve in current semver release manager.add("organizations:resolve-in-semver-release", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Add build code and build number to semver ordering + manager.add("organizations:semver-ordering-with-build-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable revocation of org auth keys when a user renames an org slug manager.add("organizations:revoke-org-auth-on-slug-rename", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable detecting SDK crashes during event processing diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index e5ecee580d7c8c..6ee43458c38361 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -41,7 +41,12 @@ ) from sentry.models.releases.exceptions import UnsafeReleaseDeletion from sentry.models.releases.release_project import ReleaseProject -from sentry.models.releases.util import ReleaseQuerySet, SemverFilter, SemverVersion +from sentry.models.releases.util import ( + ReleaseQuerySet, + SemverFilter, + SemverVersion, + SemverVersionWithBuildCode, +) from sentry.utils import metrics from sentry.utils.cache import cache from sentry.utils.db import atomic_transaction @@ -98,9 +103,12 @@ class ReleaseModelManager(BaseManager["Release"]): def get_queryset(self) -> ReleaseQuerySet: return ReleaseQuerySet(self.model, using=self._db) - def annotate_prerelease_column(self): + def annotate_prerelease_column(self) -> ReleaseQuerySet: return self.get_queryset().annotate_prerelease_column() + def annotate_build_code_column(self) -> ReleaseQuerySet: + return self.get_queryset().annotate_build_code_column() + def filter_to_semver(self) -> ReleaseQuerySet: return self.get_queryset().filter_to_semver() @@ -301,6 +309,8 @@ class Meta: SEMVER_COLS = ["major", "minor", "patch", "revision", "prerelease_case", "prerelease"] + SEMVER_COLS_WITH_BUILD_CODE = SEMVER_COLS + ["build_code_case", "build_number", "build_code"] + def __eq__(self, other: object) -> bool: """Make sure that specialized releases are only comparable to the same other specialized release. This for instance lets us treat them @@ -400,6 +410,27 @@ def semver_tuple(self) -> SemverVersion: self.prerelease, ) + @property + def semver_tuple_with_build_code(self) -> SemverVersionWithBuildCode: + if self.build_number is None and self.build_code is not None: + build_code_case = 2 + elif self.build_number is not None: + build_code_case = 1 + else: + build_code_case = 0 + + return SemverVersionWithBuildCode( + self.major, + self.minor, + self.patch, + self.revision, + 1 if self.prerelease == "" else 0, + self.prerelease, + build_code_case, + self.build_number, + self.build_code, + ) + @classmethod def get_cache_key(cls, organization_id, version) -> str: return f"release:3:{organization_id}:{md5_text(version).hexdigest()}" diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 66fa5b2829f5fe..e6694ae9559029 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -24,7 +24,19 @@ class SemverVersion( - namedtuple("SemverVersion", "major minor patch revision prerelease_case prerelease") + namedtuple( + "SemverVersion", + "major minor patch revision prerelease_case prerelease", + ) +): + pass + + +class SemverVersionWithBuildCode( + namedtuple( + "SemverVersionWithBuildCode", + "major minor patch revision prerelease_case prerelease build_code_case build_number build_code", + ) ): pass @@ -38,7 +50,7 @@ class SemverFilter: class ReleaseQuerySet(BaseQuerySet["Release"]): - def annotate_prerelease_column(self): + def annotate_prerelease_column(self) -> Self: """ Adds a `prerelease_case` column to the queryset which is used to properly sort by prerelease. We treat an empty (but not null) prerelease as higher than any @@ -50,6 +62,23 @@ def annotate_prerelease_column(self): ) ) + def annotate_build_code_column(self) -> Self: + """ + Adds a `build_code_case` column to the queryset which is used to properly sort + by build code to match compare_version behavior: + - Alphanumeric builds (NULL build_number, non-NULL build_code): case=2 (highest) + - Numeric builds (non-NULL build_number): case=1 (middle) + - No build metadata (NULL build_number, NULL build_code): case=0 (lowest) + """ + return self.annotate( + build_code_case=Case( + When(build_number__isnull=True, build_code__isnull=False, then=2), + When(build_number__isnull=False, then=1), + default=0, + output_field=models.IntegerField(), + ) + ) + def filter_to_semver(self) -> Self: """ Filters the queryset to only include semver compatible rows @@ -65,7 +94,7 @@ def filter_by_semver_build( negated: bool = False, ) -> Self: """ - Filters released by build. If the passed `build` is a numeric string, we'll filter on + Filters releases by build. If the passed `build` is a numeric string, we'll filter on `build_number` and make use of the passed operator. If it is a non-numeric string, then we'll filter on `build_code` instead. We support a wildcard only at the end of this string, so that we can filter efficiently via the index. diff --git a/src/sentry/rules/filters/latest_adopted_release_filter.py b/src/sentry/rules/filters/latest_adopted_release_filter.py index e78fc5b5eb33c3..585c6f3987f8d7 100644 --- a/src/sentry/rules/filters/latest_adopted_release_filter.py +++ b/src/sentry/rules/filters/latest_adopted_release_filter.py @@ -7,8 +7,10 @@ from django import forms from django.db.models.signals import post_delete, post_save +from sentry import features from sentry.models.environment import Environment from sentry.models.grouprelease import GroupRelease +from sentry.models.organization import Organization from sentry.models.release import Release, follows_semver_versioning_scheme from sentry.models.releaseenvironment import ReleaseEnvironment from sentry.rules import EventState @@ -156,7 +158,17 @@ def is_newer_release( and release.is_semver_release and comparison_release.is_semver_release ): - return release.semver_tuple > comparison_release.semver_tuple + organization = Organization.objects.get_from_cache(id=release.organization_id) + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + if order_by_build_code: + return ( + release.semver_tuple_with_build_code + > comparison_release.semver_tuple_with_build_code + ) + else: + return release.semver_tuple > comparison_release.semver_tuple else: release_date = release.date_released if release.date_released else release.date_added comparison_date = ( diff --git a/src/sentry/search/utils.py b/src/sentry/search/utils.py index 9146ca6fb99885..f1d252ce78a203 100644 --- a/src/sentry/search/utils.py +++ b/src/sentry/search/utils.py @@ -10,8 +10,10 @@ from django.db import DataError, connections, router from django.utils import timezone as django_timezone +from sentry import features from sentry.models.environment import Environment from sentry.models.group import STATUS_QUERY_CHOICES, Group +from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.project import Project @@ -413,11 +415,21 @@ def get_latest_release( return sorted(versions) -def _get_release_query_type_sql(query_type: LatestReleaseOrders, last: bool) -> tuple[str, str]: +def _get_release_query_type_sql( + query_type: LatestReleaseOrders, last: bool, organization_id: int +) -> tuple[str, str]: direction = "DESC" if last else "ASC" extra_conditions = "" if query_type == LatestReleaseOrders.SEMVER: - rank_order_by = f"major {direction}, minor {direction}, patch {direction}, revision {direction}, CASE WHEN (prerelease = '') THEN 1 ELSE 0 END {direction}, prerelease {direction}, sr.id {direction}" + organization = Organization.objects.get_from_cache(id=organization_id) + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + + if order_by_build_code: + rank_order_by = f"major {direction}, minor {direction}, patch {direction}, revision {direction}, CASE WHEN (prerelease = '') THEN 1 ELSE 0 END {direction}, prerelease {direction}, CASE WHEN (build_number IS NULL AND build_code IS NOT NULL) THEN 2 WHEN (build_number IS NOT NULL) THEN 1 ELSE 0 END {direction}, build_number {direction}, build_code {direction}, sr.id {direction}" + else: + rank_order_by = f"major {direction}, minor {direction}, patch {direction}, revision {direction}, CASE WHEN (prerelease = '') THEN 1 ELSE 0 END {direction}, prerelease {direction}, sr.id {direction}" extra_conditions += " AND sr.major IS NOT NULL" else: rank_order_by = f"COALESCE(date_released, date_added) {direction}" @@ -446,7 +458,9 @@ def _run_latest_release_query( if adopted: extra_conditions += " AND jt.adopted IS NOT NULL AND jt.unadopted IS NULL " - rank_order_by, query_type_conditions = _get_release_query_type_sql(query_type, True) + rank_order_by, query_type_conditions = _get_release_query_type_sql( + query_type, True, organization_id + ) extra_conditions += query_type_conditions # XXX: This query can be very inefficient for projects with a large (100k+) @@ -500,7 +514,9 @@ def get_first_last_release_for_group( ordering to order the releases. """ direction = "DESC" if last else "ASC" - rank_order_by, extra_conditions = _get_release_query_type_sql(query_type, last) + rank_order_by, extra_conditions = _get_release_query_type_sql( + query_type, last, group.project.organization_id + ) query = f""" SELECT sr.* diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index ec8720a1974318..5282c822faf062 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -246,33 +246,70 @@ def test_release_list_order_by_build_number(self) -> None: response = self.get_success_response(self.organization.slug, sort="build") self.assert_expected_versions(response, [release_1, release_3, release_2]) - def test_release_list_order_by_semver(self) -> None: - self.login_as(user=self.user) + def _test_release_list_order_by_semver_helper(self, with_build_code: bool) -> None: release_1 = self.create_release(version="test@2.2") - release_2 = self.create_release(version="test@10.0+122") + release_2 = self.create_release(version="test@10.0+1000") release_3 = self.create_release(version="test@2.2-alpha") release_4 = self.create_release(version="test@2.2.3") release_5 = self.create_release(version="test@2.20.3") release_6 = self.create_release(version="test@2.20.3.3") - release_7 = self.create_release(version="test@10.0+123") + release_7 = self.create_release(version="test@10.0+998") release_8 = self.create_release(version="test@some_thing") release_9 = self.create_release(version="random_junk") + release_10 = self.create_release(version="test@10.0+x22") + release_11 = self.create_release(version="test@10.0+a23") + release_12 = self.create_release(version="test@10.0") + release_13 = self.create_release(version="test@10.0-abc") + release_14 = self.create_release(version="test@10.0+999") response = self.get_success_response(self.organization.slug, sort="semver") - self.assert_expected_versions( - response, - [ - release_7, - release_2, - release_6, - release_5, - release_4, - release_1, - release_3, - release_9, - release_8, - ], - ) + + if with_build_code: + expected_order = [ + release_10, # test@10.0+x22 + release_11, # test@10.0+a23 + release_2, # test@10.0+1000 + release_14, # test@10.0+999 + release_7, # test@10.0+998 + release_12, # test@10.0 + release_13, # test@10.0-abc + release_6, # test@2.20.3.3 + release_5, # test@2.20.3 + release_4, # test@2.2.3 + release_1, # test@2.2 + release_3, # test@2.2-alpha + release_9, # random_junk + release_8, # test@some_thing + ] + else: + # without build code ordering, tiebreaker is date_added + expected_order = [ + release_14, # test@10.0+999 + release_12, # test@10.0 + release_11, # test@10.0+a23 + release_10, # test@10.0+x22 + release_7, # test@10.0+998 + release_2, # test@10.0+1000 + release_13, # test@10.0-abc + release_6, # test@2.20.3.3 + release_5, # test@2.20.3 + release_4, # test@2.2.3 + release_1, # test@2.2 + release_3, # test@2.2-alpha + release_9, # random_junk + release_8, # test@some_thing + ] + + self.assert_expected_versions(response, expected_order) + + def test_release_list_order_by_semver(self) -> None: + self.login_as(user=self.user) + self._test_release_list_order_by_semver_helper(with_build_code=False) + + def test_release_list_order_by_semver_with_build_code(self) -> None: + self.login_as(user=self.user) + with self.feature("organizations:semver-ordering-with-build-code"): + self._test_release_list_order_by_semver_helper(with_build_code=True) def test_query_filter(self) -> None: user = self.create_user(is_staff=False, is_superuser=False) diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index 7004f6d68d0a61..175cec33e8a647 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -11,6 +11,8 @@ from sentry.api.helpers.group_index.delete import schedule_tasks_to_delete_groups from sentry.api.helpers.group_index.update import ( get_group_list, + get_semver_releases, + greatest_semver_release, handle_assigned_to, handle_has_seen, handle_is_bookmarked, @@ -1141,3 +1143,99 @@ def test_delete_groups_deletes_seer_records_by_hash( mock_delete_seer_grouping_records_by_hash.assert_called_with( args=[self.project.id, hashes, 0] ) + + +class GetSemverReleasesTest(TestCase): + def _test_greatest_semver_releases_helper(self, with_build_code: bool) -> None: + """Test that get_semver_releases returns releases in correct semver order.""" + release_1 = self.create_release(version="test@2.2", project=self.project) + release_2 = self.create_release(version="test@10.0+1000", project=self.project) + release_3 = self.create_release(version="test@2.2-alpha", project=self.project) + release_4 = self.create_release(version="test@2.2.3", project=self.project) + release_5 = self.create_release(version="test@2.20.3", project=self.project) + release_6 = self.create_release(version="test@2.20.3.3", project=self.project) + release_7 = self.create_release(version="test@10.0+998", project=self.project) + release_8 = self.create_release(version="test@10.0+x22", project=self.project) + release_9 = self.create_release(version="test@10.0+a23", project=self.project) + release_10 = self.create_release(version="test@10.0", project=self.project) + release_11 = self.create_release(version="test@10.0-abc", project=self.project) + release_12 = self.create_release(version="test@10.0+999", project=self.project) + # Non-semver releases that will be filtered out by filter_to_semver() + self.create_release(version="test@some_thing", project=self.project) + self.create_release(version="random_junk", project=self.project) + + releases = list(get_semver_releases(self.project)) + + if with_build_code: + expected_order = [ + release_8, # test@10.0+x22 + release_9, # test@10.0+a23 + release_2, # test@10.0+1000 + release_12, # test@10.0+999 + release_7, # test@10.0+998 + release_10, # test@10.0 + release_11, # test@10.0-abc + release_6, # test@2.20.3.3 + release_5, # test@2.20.3 + release_4, # test@2.2.3 + release_1, # test@2.2 + release_3, # test@2.2-alpha + ] + + assert len(releases) == len(expected_order) + assert [r.id for r in releases] == [r.id for r in expected_order] + else: + # Without build code ordering, 10.0 releases (same semver, different build codes) + # are not in deterministic order. Just verify they're grouped at the top. + all_10_releases = { + release_2, + release_7, + release_8, + release_9, + release_10, + release_11, + release_12, + } + + assert len(releases) == 12 + assert set(releases[:7]) == all_10_releases + assert releases[7:] == [ + release_6, + release_5, + release_4, + release_1, + release_3, + ] + + def test_greatest_semver_releases(self) -> None: + """Test get_semver_releases orders releases by semver.""" + self._test_greatest_semver_releases_helper(with_build_code=False) + + def test_greatest_semver_releases_with_build_code(self) -> None: + """Test get_semver_releases orders releases by semver and build code.""" + with self.feature("organizations:semver-ordering-with-build-code"): + self._test_greatest_semver_releases_helper(with_build_code=True) + + def test_greatest_semver_release(self) -> None: + """Test that greatest_semver_release returns the highest version release.""" + self.create_release(version="test@1.0", project=self.project) + release_2 = self.create_release(version="test@2.0", project=self.project) + self.create_release(version="test@1.5", project=self.project) + + greatest = greatest_semver_release(self.project) + assert greatest is not None + assert greatest.id == release_2.id + assert greatest.version == "test@2.0" + + def test_greatest_semver_release_with_build_code(self) -> None: + """Test that greatest_semver_release returns the highest version release with the highest build code.""" + release_with_highest_build = self.create_release( + version="test@1.0+100", project=self.project + ) + self.create_release(version="test@1.0+99", project=self.project) + + with self.feature("organizations:semver-ordering-with-build-code"): + greatest = greatest_semver_release(self.project) + assert greatest is not None + assert greatest.id == release_with_highest_build.id + assert greatest.version == "test@1.0+100" diff --git a/tests/sentry/models/test_release.py b/tests/sentry/models/test_release.py index 0dbc2b503324d8..b7b77f126422ae 100644 --- a/tests/sentry/models/test_release.py +++ b/tests/sentry/models/test_release.py @@ -4,6 +4,7 @@ import pytest from django.core.exceptions import ValidationError from django.utils import timezone +from sentry_relay.processing import compare_version, parse_release from sentry.api.exceptions import InvalidRepository from sentry.api.release_search import INVALID_SEMVER_MESSAGE @@ -1978,3 +1979,46 @@ def test_get_unused_filter_allows_cleanup_with_old_group_resolutions(self): unused_filter = Release.get_unused_filter(self.cutoff_date) unused_releases = Release.objects.filter(unused_filter) assert old_release in unused_releases + + +class ReleaseOrderBySemverTestCase(TestCase): + def test_compare_version_sorts_by_build_number(self): + """ + Test that compare_version correctly sorts releases by build number with various formats. + + DESC (largest to smallest): + 1. alphanumeric builds (compared lexicographically) + 2. numeric builds (compared numerically) + 3. no build + """ + # numeric builds use numerical comparison + v1 = parse_release("app@1.0.0+999")["version_raw"] + v2 = parse_release("app@1.0.0+1000")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + + # alphanumeric builds use lexicographic comparison + v1 = parse_release("app@1.0.0+abc")["version_raw"] + v2 = parse_release("app@1.0.0+xyz")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + v1 = parse_release("app@1.0.0+z1b2c3d")["version_raw"] + v2 = parse_release("app@1.0.0+z9y8x7w")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + v1 = parse_release("app@1.0.0+build.45")["version_raw"] + v2 = parse_release("app@1.0.0+build.123")["version_raw"] + assert compare_version(v1, v2) == 1 + assert compare_version(v2, v1) == -1 + + # numeric < alphanumeric + v1 = parse_release("app@1.0.0+999")["version_raw"] + v2 = parse_release("app@1.0.0+abc")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + + # no build < with build + v_no_build = parse_release("app@1.0.0")["version_raw"] + v_with_build = parse_release("app@1.0.0+123")["version_raw"] + assert compare_version(v_no_build, v_with_build) == -1 + assert compare_version(v_with_build, v_no_build) == 1 diff --git a/tests/sentry/rules/filters/test_latest_adopted_release.py b/tests/sentry/rules/filters/test_latest_adopted_release.py index eca8d1295d75af..c969aecff84e1c 100644 --- a/tests/sentry/rules/filters/test_latest_adopted_release.py +++ b/tests/sentry/rules/filters/test_latest_adopted_release.py @@ -1,7 +1,12 @@ from datetime import UTC, datetime, timedelta -from sentry.rules.filters.latest_adopted_release_filter import LatestAdoptedReleaseFilter -from sentry.testutils.cases import RuleTestCase +from sentry.rules.filters.latest_adopted_release_filter import ( + LatestAdoptedReleaseFilter, + is_newer_release, +) +from sentry.search.utils import LatestReleaseOrders +from sentry.testutils.cases import RuleTestCase, TestCase +from sentry.testutils.helpers.features import with_feature class LatestAdoptedReleaseFilterTest(RuleTestCase): @@ -126,6 +131,101 @@ def test_semver_with_release_without_adoption(self) -> None: # Oldest release for group is 1.9, latest adopted release for environment is 1.0 self.assertPasses(rule, event_2) + @with_feature("organizations:semver-ordering-with-build-code") + def test_semver_with_build_code(self) -> None: + """ + Test that the rule uses build number when ordering releases by semver. + + Without build code ordering, whichever release is created last will get + picked as the latest adopted release for the env. Ie, the behavior that + we are changing is the fallback to insertion order (id), NOT date_added. + In prod, id and date_added are closely linked, but we specify here for + clarity. + """ + event = self.get_event() + now = datetime.now(UTC) + prod = self.create_environment(name="prod") + test = self.create_environment(name="test") + + release_9 = self.create_release( + project=event.group.project, + version="test@2.0+9", + environments=[test], + date_added=now - timedelta(days=2), + adopted=now - timedelta(days=2), + ) + self.create_group_release(group=self.event.group, release=release_9) + + release_11 = self.create_release( + project=event.group.project, + version="test@2.0+11", + environments=[test], + date_added=now - timedelta(days=2), + adopted=now - timedelta(days=2), + ) + + self.create_release( + project=event.group.project, + version="test@2.0+10", + environments=[prod], + date_added=now - timedelta(days=1), + adopted=now - timedelta(days=1), + ) + + self.create_release( + project=event.group.project, + version="test@2.0+8", + environments=[prod], + date_added=now, + adopted=now, + ) + + # The newest adopted release associated with the event's issue (2.0+9) + # is older than the latest adopted release in prod (2.0+10) + data = {"oldest_or_newest": "newest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + data = {"oldest_or_newest": "newest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + + self.create_group_release(group=self.event.group, release=release_11) + + # The newest adopted release associated with the event's issue (2.0+11) + # is newer than the latest adopted release in prod (2.0+10) + data = {"oldest_or_newest": "newest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + data = {"oldest_or_newest": "newest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + + # The oldest adopted release associated with the event's issue (2.0+9) + # is older than the latest adopted release in prod (2.0+10) + data = {"oldest_or_newest": "oldest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + data = {"oldest_or_newest": "oldest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + + self.create_release( + project=event.group.project, + version="test@2.0+a", + environments=[prod], + date_added=now - timedelta(days=3), + adopted=now - timedelta(days=3), + ) + + # The newest adopted release associated with the event's issue (2.0+11) + # is older than the latest adopted release in prod (2.0+a) + data = {"oldest_or_newest": "newest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + data = {"oldest_or_newest": "newest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + def test_no_adopted_release(self) -> None: event = self.get_event() now = datetime.now(UTC) @@ -196,3 +296,80 @@ def test_date(self) -> None: self.create_group_release(group=group_3, release=middle_release) self.assertDoesNotPass(rule, event_3) + + +class IsNewerReleaseTest(TestCase): + def _test_is_newer_release_semver_helper(self, with_build_code: bool) -> None: + """Test that is_newer_release correctly compares releases with semver and build code.""" + older_release = self.create_release(version="test@1.0", project=self.project) + newer_release = self.create_release(version="test@2.0", project=self.project) + newer_release_older_numeric = self.create_release( + version="test@2.0+100", project=self.project + ) + newer_release_newer_numeric = self.create_release( + version="test@2.0+200", project=self.project + ) + newer_release_alpha = self.create_release(version="test@2.0+zzz", project=self.project) + + if with_build_code: + assert is_newer_release(newer_release, older_release, LatestReleaseOrders.SEMVER) + + # numeric build codes are compared numerically + assert is_newer_release( + newer_release_newer_numeric, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + + # alphanumeric builds are always newer than numeric builds + assert is_newer_release( + newer_release_alpha, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert is_newer_release( + newer_release_alpha, newer_release_newer_numeric, LatestReleaseOrders.SEMVER + ) + + # releases without build are always older than releases with build + assert is_newer_release( + newer_release_older_numeric, newer_release, LatestReleaseOrders.SEMVER + ) + assert is_newer_release(newer_release_alpha, newer_release, LatestReleaseOrders.SEMVER) + else: + assert is_newer_release(newer_release, older_release, LatestReleaseOrders.SEMVER) + + # all releases with version 1.0 are considered equal + + assert not is_newer_release( + newer_release_newer_numeric, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( + newer_release_older_numeric, newer_release_newer_numeric, LatestReleaseOrders.SEMVER + ) + + assert not is_newer_release( + newer_release_older_numeric, newer_release_newer_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( + newer_release_newer_numeric, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + + assert not is_newer_release( + newer_release_alpha, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( + newer_release_older_numeric, newer_release_alpha, LatestReleaseOrders.SEMVER + ) + + assert not is_newer_release( + newer_release, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( + newer_release_older_numeric, newer_release, LatestReleaseOrders.SEMVER + ) + + def test_is_newer_release_semver(self) -> None: + """Test is_newer_release compares releases by semver.""" + self._test_is_newer_release_semver_helper(with_build_code=False) + + def test_is_newer_release_semver_with_build_code(self) -> None: + """Test is_newer_release compares releases by semver and build code.""" + with self.feature("organizations:semver-ordering-with-build-code"): + self._test_is_newer_release_semver_helper(with_build_code=True) diff --git a/tests/sentry/search/test_utils.py b/tests/sentry/search/test_utils.py index 1e6f2b99e8d0a5..15742329c6b398 100644 --- a/tests/sentry/search/test_utils.py +++ b/tests/sentry/search/test_utils.py @@ -831,6 +831,23 @@ def test_multiple_projects_mixed_versions(self) -> None: release_1.version, ] + def test_semver_with_build_code(self) -> None: + """Test that get_latest_release handles build code ordering.""" + self.create_release(version="test@1.0.0+1000") + self.create_release(version="test@1.0.0+999") + release_highest_build = self.create_release(version="test@1.0.0+zzz") + self.create_release(version="test@1.0.0") + release_latest_created = self.create_release(version="test@1.0.0+aaa") + + # no build code ordering -> tiebreaker is insertion order (id) + result = get_latest_release([self.project], None) + assert result == [release_latest_created.version] + + # with build code ordering -> alphanumeric builds are highest + with self.feature("organizations:semver-ordering-with-build-code"): + result = get_latest_release([self.project], None) + assert result == [release_highest_build.version] + class GetFirstLastReleaseForGroupTest(TestCase): def test_date(self) -> None: @@ -894,6 +911,31 @@ def test_semver(self) -> None: group_2, LatestReleaseOrders.SEMVER, False ) + def test_semver_with_build_code(self) -> None: + """Test that get_first_last_release_for_group handles build code ordering.""" + release_numeric_low = self.create_release(version="test@1.0.0+999") + release_numeric_high = self.create_release(version="test@1.0.0+1000") + release_alpha = self.create_release(version="test@1.0.0+zzz") + release_no_build = self.create_release(version="test@1.0.0") + + self.create_group_release(group=self.group, release=release_numeric_low) + self.create_group_release(group=self.group, release=release_numeric_high) + self.create_group_release(group=self.group, release=release_alpha) + self.create_group_release(group=self.group, release=release_no_build) + + # no build code ordering -> tiebreaker is sr.id + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, True) + assert result.version == release_no_build.version # latest created + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, False) + assert result.version == release_numeric_low.version # oldest created + + # with build code ordering -> alphanumeric builds are highest, no build is lowest + with self.feature("organizations:semver-ordering-with-build-code"): + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, True) + assert result.version == release_alpha.version + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, False) + assert result.version == release_no_build.version + @control_silo_test class ConvertUserTagTest(TestCase):