Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b3cd378
add build number sorting
srest2021 Nov 4, 2025
0eb0eca
ensure semver order test fails without build number ordering
srest2021 Nov 4, 2025
afac5b0
add compare_version test
srest2021 Nov 4, 2025
e479720
clean up test
srest2021 Nov 4, 2025
185a8a1
remove annotation
srest2021 Nov 5, 2025
b78407a
add lexicographic sort on build_code
srest2021 Nov 5, 2025
e9166d9
Merge branch 'master' into srest2021/REPLAY-803
srest2021 Nov 5, 2025
3096e53
make sure build code test doesnt pass by coincidence
srest2021 Nov 5, 2025
2ac040b
Merge branch 'master' into srest2021/REPLAY-803
srest2021 Nov 5, 2025
e3a53a7
add build_number_case annotation
srest2021 Nov 5, 2025
8328882
rename case column
srest2021 Nov 6, 2025
d3b19a0
rename case column
srest2021 Nov 6, 2025
4f987e8
fix
srest2021 Nov 6, 2025
ce127dc
missed some renaming
srest2021 Nov 6, 2025
2d46a94
Merge branch 'master' into srest2021/REPLAY-803
srest2021 Nov 6, 2025
8d23660
fix typing
srest2021 Nov 6, 2025
354b7f2
add feature flag
srest2021 Nov 7, 2025
63b9dab
dont need to update ordering in this file
srest2021 Nov 10, 2025
b381471
more tests
srest2021 Nov 10, 2025
f35d7b0
more tests
srest2021 Nov 10, 2025
83a2701
cleaning up
srest2021 Nov 10, 2025
cc73245
more tests & clean up feature flag checks
srest2021 Nov 10, 2025
ffdaf0d
remove unnecessary sorting in backend.py; update filter aliases
srest2021 Nov 10, 2025
7c2ce34
Merge branch 'master' into srest2021/REPLAY-803
srest2021 Nov 10, 2025
22ae3bf
fix flaky test
srest2021 Nov 10, 2025
30cc2d9
fix typing
srest2021 Nov 10, 2025
d07da24
small naming change
srest2021 Nov 10, 2025
960111e
update utils
srest2021 Nov 10, 2025
ba16113
fix failing test
srest2021 Nov 10, 2025
de556e9
Merge branch 'master' into srest2021/REPLAY-803
srest2021 Nov 10, 2025
3486a98
fix typing again
srest2021 Nov 10, 2025
496d672
Merge branch 'master' into srest2021/REPLAY-803
srest2021 Nov 11, 2025
205504e
revert filter changes
srest2021 Nov 11, 2025
22984f1
oops
srest2021 Nov 11, 2025
c77a856
revert backend.py changes
srest2021 Nov 11, 2025
0c479bc
latest adopted release test
srest2021 Nov 12, 2025
2751910
fix comment
srest2021 Nov 12, 2025
f7cb5dc
more test coverage for latest adopted release
srest2021 Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/sentry/api/endpoints/organization_releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions src/sentry/api/helpers/group_index/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/sentry/models/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()}"
Expand Down
35 changes: 32 additions & 3 deletions src/sentry/models/releases/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion src/sentry/rules/filters/latest_adopted_release_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
24 changes: 20 additions & 4 deletions src/sentry/search/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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+)
Expand Down Expand Up @@ -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.*
Expand Down
73 changes: 55 additions & 18 deletions tests/sentry/api/endpoints/test_organization_releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Member Author

@srest2021 srest2021 Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switch the order in which release_2 and release_7 are created so the build_number sorting doesn't coincidentally pass due to insertion order.

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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This order now matches what we see in test_compare_version_sorts_by_build_number below

]
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)
Expand Down
Loading
Loading