From c9b78bc65de35fb9180315aafdc07b5f5efc837e Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:40:10 -0700 Subject: [PATCH 01/10] feat(api): Accept project slugs in release endpoints Allow release thresholds and release details to resolve project filters by ID or slug while keeping public query parameter docs ID-focused. Co-Authored-By: OpenCode --- .../release_threshold_index.py | 14 +++-- .../release_threshold_status_index.py | 49 ++++++++++++++-- .../endpoints/organization_release_details.py | 27 ++++++--- .../test_release_threshold_status.py | 58 +++++++++++++++++++ .../test_release_thresholds_index.py | 15 +++++ .../test_organization_release_details.py | 35 +++++++++++ 6 files changed, 180 insertions(+), 18 deletions(-) diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py index f5f1b3234e02..3b07964911e1 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py @@ -10,6 +10,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint +from sentry.api.helpers.projects import ProjectIdOrSlug, ProjectIdOrSlugField from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize from sentry.models.organization import Organization @@ -18,16 +19,14 @@ class ReleaseThresholdIndexGETData(TypedDict, total=False): environment: list[str] - project: list[int] + project: list[ProjectIdOrSlug] class ReleaseThresholdIndexGETValidator(serializers.Serializer[ReleaseThresholdIndexGETData]): environment = serializers.ListField( required=False, allow_empty=True, child=serializers.CharField() ) - project = serializers.ListField( - required=True, allow_empty=False, child=serializers.IntegerField() - ) + project = serializers.ListField(required=True, allow_empty=False, child=ProjectIdOrSlugField()) @cell_silo_endpoint @@ -38,8 +37,13 @@ class ReleaseThresholdIndexEndpoint(OrganizationEndpoint): } def get(self, request: Request, organization: Organization) -> HttpResponse: + query_params = request.query_params.copy() + if "project" in query_params: + query_params.setlist( + "project", [project for project in query_params.getlist("project") if project] + ) validator = ReleaseThresholdIndexGETValidator( - data=request.query_params, + data=query_params, ) if not validator.is_valid(): return Response(validator.errors, status=400) diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py index 7b4a47f02d32..1275fd182fd8 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py @@ -28,6 +28,11 @@ get_errors_counts_timeseries_by_project_and_release, get_new_issue_counts, ) +from sentry.api.helpers.projects import ( + ProjectIdOrSlug, + ProjectIdOrSlugField, + parse_id_or_slug_params, +) from sentry.api.serializers import serialize from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.examples.release_threshold_examples import ReleaseThresholdExamples @@ -54,6 +59,7 @@ class ReleaseThresholdStatusIndexData(TypedDict, total=False): start: datetime end: datetime environment: list[str] + project: list[ProjectIdOrSlug] projectSlug: list[str] release: list[str] @@ -82,9 +88,15 @@ class ReleaseThresholdStatusIndexSerializer( projectSlug = serializers.ListField( required=False, allow_empty=True, - child=serializers.CharField(), + child=serializers.CharField(allow_blank=True), help_text=("A list of project slugs to filter your results by."), ) + project = serializers.ListField( + required=False, + allow_empty=True, + child=ProjectIdOrSlugField(), + help_text=("A list of project IDs or slugs to filter your results by."), + ) release = serializers.ListField( required=False, allow_empty=True, @@ -136,20 +148,45 @@ def get( # NOTE: start/end parameters determine window to query for releases # This is NOT the window to query snuba for event data - nor the individual threshold windows # ======================================================================== - serializer = ReleaseThresholdStatusIndexSerializer( - data=request.query_params, - ) + # Preserve legacy projectSlug precedence while treating blank project filters as absent. + query_params = request.query_params.copy() + project_slug_params = [slug for slug in query_params.getlist("projectSlug") if slug] + if "projectSlug" in query_params: + query_params.setlist("projectSlug", project_slug_params) + if project_slug_params: + query_params.pop("project", None) + elif "project" in query_params: + query_params.setlist( + "project", [project for project in query_params.getlist("project") if project] + ) + + serializer = ReleaseThresholdStatusIndexSerializer(data=query_params) if not serializer.is_valid(): return Response(as_validation_errors(serializer), status=400) environments_list = serializer.validated_data.get( "environment" ) # list of environment names - project_slug_list = serializer.validated_data.get("projectSlug") + project_slug_list = [ + slug for slug in serializer.validated_data.get("projectSlug", []) if slug + ] or None + requested_project = parse_id_or_slug_params(serializer.validated_data.get("project", [])) releases_list = serializer.validated_data.get("release") # list of release versions + project_ids: set[int] | None = None + project_slugs: list[str] | set[str] | None = project_slug_list + if project_slug_list is None: + if requested_project.ids and not requested_project.slugs: + project_ids = requested_project.ids + elif requested_project.slugs and not requested_project.ids: + project_slugs = requested_project.slugs + try: filter_params = self.get_filter_params( - request, organization, date_filter_optional=True, project_slugs=project_slug_list + request, + organization, + date_filter_optional=True, + project_ids=project_ids, + project_slugs=project_slugs, ) except NoProjects: raise NoProjects("No projects available") diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index bc4ebd3370ec..691f3a15ea0b 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -1,7 +1,7 @@ import sentry_sdk from django.db.models import Q -from drf_spectacular.utils import extend_schema, extend_schema_serializer -from rest_framework.exceptions import ParseError +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_serializer +from rest_framework.exceptions import ParseError, ValidationError from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ListField @@ -17,6 +17,7 @@ get_stats_period_detail, ) from sentry.api.exceptions import ConflictError, InvalidRepository, ResourceDoesNotExist +from sentry.api.helpers.projects import ProjectIdOrSlugField, parse_id_or_slug_params from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework import ( ReleaseHeadCommitSerializer, @@ -314,7 +315,13 @@ class OrganizationReleaseDetailsEndpoint( parameters=[ GlobalParams.ORG_ID_OR_SLUG, ReleaseParams.VERSION, - ReleaseParams.PROJECT_ID, + OpenApiParameter( + name="project", + location="query", + required=False, + type=int, + description="The project ID to filter by.", + ), ReleaseParams.HEALTH, ReleaseParams.ADOPTION_STAGES, ReleaseParams.SUMMARY_STATS_PERIOD, @@ -362,15 +369,21 @@ def get( if not self.has_release_permission(request, organization, release): raise ResourceDoesNotExist - # Validate project access when project_id is provided + # Validate project access when a project identifier is provided. project = None if project_id: try: - project_id_int = int(project_id) - except ValueError: + project_id_or_slug = ProjectIdOrSlugField().run_validation(project_id) + except ValidationError: + raise ParseError(detail="Invalid project") + requested_project = parse_id_or_slug_params([project_id_or_slug]) + if not requested_project.has_values or requested_project.has_all_projects_sentinel: raise ParseError(detail="Invalid project") validated_projects = self.get_projects( - request, organization, project_ids={project_id_int} + request, + organization, + project_ids=requested_project.ids or None, + project_slugs=requested_project.slugs or None, ) if not validated_projects: raise ResourceDoesNotExist diff --git a/tests/sentry/api/endpoints/release_thresholds/test_release_threshold_status.py b/tests/sentry/api/endpoints/release_thresholds/test_release_threshold_status.py index 6cd07fe3d900..cde3b874356c 100644 --- a/tests/sentry/api/endpoints/release_thresholds/test_release_threshold_status.py +++ b/tests/sentry/api/endpoints/release_thresholds/test_release_threshold_status.py @@ -407,6 +407,64 @@ def test_get_success_project_slug_filter(self) -> None: r2_keys = [k for k, v in data.items() if k.split("-")[1] == self.release2.version] assert len(r2_keys) == 0 + def test_get_success_project_param_slug_filter(self) -> None: + now = datetime.now(UTC) + yesterday = now - timedelta(hours=24) + + project_slug_response = self.get_success_response( + self.organization.slug, start=yesterday, end=now, projectSlug=[self.project2.slug] + ) + project_response = self.get_success_response( + self.organization.slug, start=yesterday, end=now, project=[self.project2.slug] + ) + + assert project_response.data == project_slug_response.data + + def test_get_success_project_param_mixed_id_and_slug_filter(self) -> None: + now = datetime.now(UTC) + yesterday = now - timedelta(hours=24) + + response = self.get_success_response( + self.organization.slug, + start=yesterday, + end=now, + project=[str(self.project1.id), self.project2.slug], + ) + + assert set(response.data.keys()) == { + f"{self.project1.slug}-{self.release1.version}", + f"{self.project1.slug}-{self.release2.version}", + f"{self.project2.slug}-{self.release1.version}", + } + + def test_get_success_project_slug_takes_precedence_over_project_id(self) -> None: + now = datetime.now(UTC) + yesterday = now - timedelta(hours=24) + + response = self.get_success_response( + self.organization.slug, + start=yesterday, + end=now, + projectSlug=[self.project2.slug], + project=[str(self.project1.id)], + ) + + assert set(response.data.keys()) == {f"{self.project2.slug}-{self.release1.version}"} + + def test_get_success_empty_project_slug_falls_back_to_project_filter(self) -> None: + now = datetime.now(UTC) + yesterday = now - timedelta(hours=24) + + response = self.get_success_response( + self.organization.slug, + start=yesterday, + end=now, + projectSlug=[""], + project=[self.project2.slug], + ) + + assert set(response.data.keys()) == {f"{self.project2.slug}-{self.release1.version}"} + @patch( "sentry.api.endpoints.release_thresholds.release_threshold_status_index.fetch_sessions_data" ) diff --git a/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py b/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py index 0bc3882fbb25..1e2bcc9cf073 100644 --- a/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py +++ b/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py @@ -49,6 +49,21 @@ def test_get_valid_project(self) -> None: assert created_threshold["environment"]["id"] == str(self.canary_environment.id) assert created_threshold["environment"]["name"] == self.canary_environment.name + def test_get_valid_project_slug(self) -> None: + ReleaseThreshold.objects.create( + threshold_type=0, + trigger_type=0, + value=100, + window_in_seconds=1800, + project=self.project, + environment=self.canary_environment, + ) + + response = self.get_success_response(self.organization.slug, project=self.project.slug) + + assert len(response.data) == 1 + assert response.data[0]["project"]["id"] == str(self.project.id) + def test_get_invalid_environment(self) -> None: self.get_error_response(self.organization.slug, environment="foo bar", project="-1") diff --git a/tests/sentry/releases/endpoints/test_organization_release_details.py b/tests/sentry/releases/endpoints/test_organization_release_details.py index 5c34b43ef742..d8158e09c2b9 100644 --- a/tests/sentry/releases/endpoints/test_organization_release_details.py +++ b/tests/sentry/releases/endpoints/test_organization_release_details.py @@ -130,6 +130,9 @@ def test_wrong_project(self) -> None: response = self.client.get(url, {"project": self.project1.id}) assert response.status_code == 200 + response = self.client.get(url, {"project": self.project1.slug}) + assert response.status_code == 200 + def test_project_from_another_org_is_rejected(self) -> None: """Supplying a project_id belonging to a different organization must not leak session health data (IDOR check).""" @@ -171,6 +174,38 @@ def test_project_user_has_no_access_to_is_rejected(self) -> None: response = self.client.get(url, {"project": no_access_project.id}) assert response.status_code in (403, 404) + def test_all_project_id_sentinel_is_rejected(self) -> None: + release = Release.objects.create(organization_id=self.organization.id, version="abcabcabc") + release.add_project(self.project1) + + self.create_member(teams=[self.team1], user=self.user1, organization=self.organization) + self.login_as(user=self.user1) + + url = reverse( + "sentry-api-0-organization-release-details", + kwargs={"organization_id_or_slug": self.organization.slug, "version": release.version}, + ) + + response = self.client.get(url, {"project": "-1"}) + assert response.status_code == 400 + assert response.data["detail"] == "Invalid project" + + def test_all_project_slug_sentinel_is_rejected(self) -> None: + release = Release.objects.create(organization_id=self.organization.id, version="abcabcabc") + release.add_project(self.project1) + + self.create_member(teams=[self.team1], user=self.user1, organization=self.organization) + self.login_as(user=self.user1) + + url = reverse( + "sentry-api-0-organization-release-details", + kwargs={"organization_id_or_slug": self.organization.slug, "version": release.version}, + ) + + response = self.client.get(url, {"project": "$all"}) + assert response.status_code == 400 + assert response.data["detail"] == "Invalid project" + def test_correct_project_contains_current_project_meta(self) -> None: """ Test that shows when correct project id is passed to the request, `sessionsLowerBound`, From fc62835c1d655a1843ada710a4f4c802a779e0ba Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:01:07 -0700 Subject: [PATCH 02/10] docs(api): Document release project slug filter Document the release details project query parameter as accepting either a project ID or slug and expose it as a string-shaped query value. Co-Authored-By: OpenCode --- src/sentry/releases/endpoints/organization_release_details.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index 691f3a15ea0b..40765f89c5e0 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -319,8 +319,8 @@ class OrganizationReleaseDetailsEndpoint( name="project", location="query", required=False, - type=int, - description="The project ID to filter by.", + type=str, + description="The project ID or slug to filter by.", ), ReleaseParams.HEALTH, ReleaseParams.ADOPTION_STAGES, From f99606909f1b77a43ca49e805e4812b2ce8f6c43 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:21:14 -0700 Subject: [PATCH 03/10] ref(api): Reuse project query normalization in releases Use the shared organization helper for legacy projectSlug precedence before release threshold status validation. Co-Authored-By: OpenCode --- .../release_threshold_status_index.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py index 1275fd182fd8..554a48faac8d 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py @@ -148,17 +148,7 @@ def get( # NOTE: start/end parameters determine window to query for releases # This is NOT the window to query snuba for event data - nor the individual threshold windows # ======================================================================== - # Preserve legacy projectSlug precedence while treating blank project filters as absent. - query_params = request.query_params.copy() - project_slug_params = [slug for slug in query_params.getlist("projectSlug") if slug] - if "projectSlug" in query_params: - query_params.setlist("projectSlug", project_slug_params) - if project_slug_params: - query_params.pop("project", None) - elif "project" in query_params: - query_params.setlist( - "project", [project for project in query_params.getlist("project") if project] - ) + query_params = self.get_query_params_with_project_slug_precedence(request) serializer = ReleaseThresholdStatusIndexSerializer(data=query_params) if not serializer.is_valid(): From 50b05bf19a36dde61e66951c1d98944022f10c1f Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:43:43 -0700 Subject: [PATCH 04/10] ref(api): Simplify release threshold project params Use the shared blank-project query cleanup helper before release threshold index validation instead of mutating query params inline. Co-Authored-By: OpenCode --- .../release_thresholds/release_threshold_index.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py index 3b07964911e1..2b29ac08a16e 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py @@ -37,13 +37,8 @@ class ReleaseThresholdIndexEndpoint(OrganizationEndpoint): } def get(self, request: Request, organization: Organization) -> HttpResponse: - query_params = request.query_params.copy() - if "project" in query_params: - query_params.setlist( - "project", [project for project in query_params.getlist("project") if project] - ) validator = ReleaseThresholdIndexGETValidator( - data=query_params, + data=self.get_query_params_without_empty_project_params(request), ) if not validator.is_valid(): return Response(validator.errors, status=400) From 8b486a5d1eca6a6b84bc7c915d02a919d3d96142 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:54:02 -0700 Subject: [PATCH 05/10] docs(api): Type release project filter as ID or slug Use the shared integer-or-string schema for the organization release details project filter so generated clients can keep passing numeric project IDs. Co-Authored-By: OpenCode --- .../releases/endpoints/organization_release_details.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index 40765f89c5e0..909ff3a635c7 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -17,7 +17,11 @@ get_stats_period_detail, ) from sentry.api.exceptions import ConflictError, InvalidRepository, ResourceDoesNotExist -from sentry.api.helpers.projects import ProjectIdOrSlugField, parse_id_or_slug_params +from sentry.api.helpers.projects import ( + PROJECT_ID_OR_SLUG_SCHEMA, + ProjectIdOrSlugField, + parse_id_or_slug_params, +) from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework import ( ReleaseHeadCommitSerializer, @@ -319,7 +323,7 @@ class OrganizationReleaseDetailsEndpoint( name="project", location="query", required=False, - type=str, + type=PROJECT_ID_OR_SLUG_SCHEMA, description="The project ID or slug to filter by.", ), ReleaseParams.HEALTH, From 72dd86def2409f440b9e7ae2b6403be2bc76e0c4 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:20:30 -0700 Subject: [PATCH 06/10] fix(api): Handle mixed release threshold projects Allow explicit project ID and slug sets to flow through project resolution so release threshold status filters do not broaden unexpectedly. Reject the slug for release threshold index filters while preserving numeric all-project sentinel behavior.\n\nCo-Authored-By: OpenCode --- src/sentry/api/bases/organization.py | 12 ++++-------- .../release_thresholds/release_threshold_index.py | 6 ++++++ .../release_threshold_status_index.py | 6 ++---- tests/sentry/api/bases/test_organization.py | 13 +++++++++++++ .../test_release_thresholds_index.py | 5 +++++ 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index 0636c6561eb1..e7cd168a83a4 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -394,16 +394,12 @@ def get_projects( :return: A list of Project objects, or raises PermissionDenied. When project_ids or project_slugs are explicitly provided, the returned list is guaranteed non-empty (or PermissionDenied is raised). - NOTE: Passing both project_ids and project_slugs raises ``ParseError``. """ qs = Project.objects.filter(organization_id=organization.id, status=ObjectStatus.ACTIVE) - if project_slugs and project_ids: - raise ParseError(detail="Cannot query for both ids and slugs") - - if project_ids: - requested_projects = ParsedProjectIdOrSlugParams(ids=project_ids, slugs=set()) - elif project_slugs: - requested_projects = ParsedProjectIdOrSlugParams(ids=set(), slugs=set(project_slugs)) + if project_ids or project_slugs: + requested_projects = ParsedProjectIdOrSlugParams( + ids=project_ids or set(), slugs=set(project_slugs or ()) + ) else: requested_projects = self.get_requested_project_params_unchecked(request) ids = requested_projects.ids diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py index 2b29ac08a16e..b6d1827dd233 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_index.py @@ -13,6 +13,7 @@ from sentry.api.helpers.projects import ProjectIdOrSlug, ProjectIdOrSlugField from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize +from sentry.constants import ALL_ACCESS_PROJECTS_SLUG from sentry.models.organization import Organization from sentry.models.release_threshold.release_threshold import ReleaseThreshold @@ -28,6 +29,11 @@ class ReleaseThresholdIndexGETValidator(serializers.Serializer[ReleaseThresholdI ) project = serializers.ListField(required=True, allow_empty=False, child=ProjectIdOrSlugField()) + def validate_project(self, value: list[ProjectIdOrSlug]) -> list[ProjectIdOrSlug]: + if ALL_ACCESS_PROJECTS_SLUG in value: + raise serializers.ValidationError("Invalid project") + return value + @cell_silo_endpoint class ReleaseThresholdIndexEndpoint(OrganizationEndpoint): diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py index 554a48faac8d..039505b0f3e1 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py @@ -165,10 +165,8 @@ def get( project_ids: set[int] | None = None project_slugs: list[str] | set[str] | None = project_slug_list if project_slug_list is None: - if requested_project.ids and not requested_project.slugs: - project_ids = requested_project.ids - elif requested_project.slugs and not requested_project.ids: - project_slugs = requested_project.slugs + project_ids = requested_project.ids or None + project_slugs = requested_project.slugs or None try: filter_params = self.get_filter_params( diff --git a/tests/sentry/api/bases/test_organization.py b/tests/sentry/api/bases/test_organization.py index 5c856fbf1db6..ff4e26baa87b 100644 --- a/tests/sentry/api/bases/test_organization.py +++ b/tests/sentry/api/bases/test_organization.py @@ -666,6 +666,19 @@ def test_project_param_with_mixed_ids_and_slugs(self) -> None: assert {p.id for p in result} == {self.project_1.id, self.project_2.id} + def test_explicit_project_ids_and_slugs(self) -> None: + self.create_team_membership(user=self.user, team=self.team_3) + request = self.build_request() + + result = self.endpoint.get_projects( + request, + self.org, + project_ids={self.project_1.id}, + project_slugs={self.project_2.slug}, + ) + + assert {p.id for p in result} == {self.project_1.id, self.project_2.id} + def test_project_param_with_nonexistent_slug(self) -> None: self.create_team_membership(user=self.user, team=self.team_1) request = self.build_request(project=["nonexistent-slug"]) diff --git a/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py b/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py index 1e2bcc9cf073..fa7bcc7e0917 100644 --- a/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py +++ b/tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py @@ -22,6 +22,11 @@ def setUp(self) -> None: def test_get_invalid_project(self) -> None: self.get_error_response(self.organization.slug, project="foo bar") + def test_get_all_projects_slug_is_invalid(self) -> None: + response = self.get_error_response(self.organization.slug, project="$all") + + assert response.status_code == 400 + def test_get_no_project(self) -> None: self.get_error_response(self.organization.slug) From d1b3232a33ee3f14059716e8e2ea57760875b402 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:15:09 -0700 Subject: [PATCH 07/10] ref(api): Clarify release project parsing Make release project ID-or-slug parsing more explicit while preserving projectSlug precedence and all-project sentinel rejection. Co-Authored-By: OpenCode --- .../release_threshold_status_index.py | 24 ++++++++----------- .../endpoints/organization_release_details.py | 21 +++++++++++----- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py index 039505b0f3e1..66f0ad59089c 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py @@ -154,17 +154,14 @@ def get( if not serializer.is_valid(): return Response(as_validation_errors(serializer), status=400) - environments_list = serializer.validated_data.get( - "environment" - ) # list of environment names - project_slug_list = [ - slug for slug in serializer.validated_data.get("projectSlug", []) if slug - ] or None - requested_project = parse_id_or_slug_params(serializer.validated_data.get("project", [])) - releases_list = serializer.validated_data.get("release") # list of release versions + validated_data = serializer.validated_data + environments_list = validated_data.get("environment") # list of environment names + releases_list = validated_data.get("release") # list of release versions + project_ids: set[int] | None = None - project_slugs: list[str] | set[str] | None = project_slug_list - if project_slug_list is None: + project_slugs = {slug for slug in validated_data.get("projectSlug", []) if slug} or None + if project_slugs is None: + requested_project = parse_id_or_slug_params(validated_data.get("project", [])) project_ids = requested_project.ids or None project_slugs = requested_project.slugs or None @@ -179,9 +176,8 @@ def get( except NoProjects: raise NoProjects("No projects available") - # Use validated project IDs from get_filter_params instead of raw user input. - # The raw project_slug_list could contain slugs for projects the user doesn't - # have access to, bypassing the permission checks in get_projects(). + # Use project IDs from get_filter_params instead of raw project filters so + # project access is checked before fetching threshold data. validated_project_ids = set(filter_params["project_id"]) start: datetime | None = filter_params["start"] @@ -228,7 +224,7 @@ def get( "Fetched releases", extra={ "results": len(queryset), - "project_slugs": project_slug_list, + "project_slugs": project_slugs, "releases": releases_list, "environments": environments_list, }, diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index 909ff3a635c7..9b7499630cc9 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -20,7 +20,6 @@ from sentry.api.helpers.projects import ( PROJECT_ID_OR_SLUG_SCHEMA, ProjectIdOrSlugField, - parse_id_or_slug_params, ) from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework import ( @@ -43,6 +42,7 @@ as_validation_errors, ) from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.constants import ALL_ACCESS_PROJECT_ID, ALL_ACCESS_PROJECTS_SLUG from sentry.models.activity import Activity from sentry.models.organization import Organization from sentry.models.release import Release, ReleaseStatus @@ -380,14 +380,23 @@ def get( project_id_or_slug = ProjectIdOrSlugField().run_validation(project_id) except ValidationError: raise ParseError(detail="Invalid project") - requested_project = parse_id_or_slug_params([project_id_or_slug]) - if not requested_project.has_values or requested_project.has_all_projects_sentinel: - raise ParseError(detail="Invalid project") + + project_ids: set[int] | None = None + project_slugs: set[str] | None = None + if isinstance(project_id_or_slug, int): + if project_id_or_slug == ALL_ACCESS_PROJECT_ID: + raise ParseError(detail="Invalid project") + project_ids = {project_id_or_slug} + else: + if project_id_or_slug == ALL_ACCESS_PROJECTS_SLUG: + raise ParseError(detail="Invalid project") + project_slugs = {project_id_or_slug} + validated_projects = self.get_projects( request, organization, - project_ids=requested_project.ids or None, - project_slugs=requested_project.slugs or None, + project_ids=project_ids, + project_slugs=project_slugs, ) if not validated_projects: raise ResourceDoesNotExist From efb45cba7a789db77b780d2c57f5f56386de205f Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:37:41 -0700 Subject: [PATCH 08/10] fix(api): Preserve release project_id filter Keep the release details project_id query parameter documented and accepted while adding project as the ID-or-slug alias. Co-Authored-By: OpenCode --- .../release_threshold_status_index.py | 14 +++++++------- .../endpoints/organization_release_details.py | 5 +++-- .../endpoints/test_organization_release_details.py | 3 +++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py index 66f0ad59089c..e04421aec003 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py @@ -59,9 +59,9 @@ class ReleaseThresholdStatusIndexData(TypedDict, total=False): start: datetime end: datetime environment: list[str] - project: list[ProjectIdOrSlug] projectSlug: list[str] release: list[str] + project: list[ProjectIdOrSlug] class ReleaseThresholdStatusIndexSerializer( @@ -91,18 +91,18 @@ class ReleaseThresholdStatusIndexSerializer( child=serializers.CharField(allow_blank=True), help_text=("A list of project slugs to filter your results by."), ) - project = serializers.ListField( - required=False, - allow_empty=True, - child=ProjectIdOrSlugField(), - help_text=("A list of project IDs or slugs to filter your results by."), - ) release = serializers.ListField( required=False, allow_empty=True, child=serializers.CharField(), help_text=("A list of release versions to filter your results by."), ) + project = serializers.ListField( + required=False, + allow_empty=True, + child=ProjectIdOrSlugField(), + help_text=("A list of project IDs or slugs to filter your results by."), + ) def validate(self, data: ReleaseThresholdStatusIndexData) -> ReleaseThresholdStatusIndexData: if data["start"] >= data["end"]: diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index 9b7499630cc9..d523127a83fe 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -319,12 +319,13 @@ class OrganizationReleaseDetailsEndpoint( parameters=[ GlobalParams.ORG_ID_OR_SLUG, ReleaseParams.VERSION, + ReleaseParams.PROJECT_ID, OpenApiParameter( name="project", location="query", required=False, type=PROJECT_ID_OR_SLUG_SCHEMA, - description="The project ID or slug to filter by.", + description="The project ID or slug to filter by. Overrides project_id when both are sent.", ), ReleaseParams.HEALTH, ReleaseParams.ADOPTION_STAGES, @@ -351,7 +352,7 @@ def get( """ # Dictionary responsible for storing selected project meta data current_project_meta = {} - project_id = request.GET.get("project") + project_id = request.GET.get("project") or request.GET.get("project_id") with_health = request.GET.get("health") == "1" with_adoption_stages = request.GET.get("adoptionStages") == "1" summary_stats_period = request.GET.get("summaryStatsPeriod") or "14d" diff --git a/tests/sentry/releases/endpoints/test_organization_release_details.py b/tests/sentry/releases/endpoints/test_organization_release_details.py index d8158e09c2b9..09cd45ec4a07 100644 --- a/tests/sentry/releases/endpoints/test_organization_release_details.py +++ b/tests/sentry/releases/endpoints/test_organization_release_details.py @@ -130,6 +130,9 @@ def test_wrong_project(self) -> None: response = self.client.get(url, {"project": self.project1.id}) assert response.status_code == 200 + response = self.client.get(url, {"project_id": self.project1.id}) + assert response.status_code == 200 + response = self.client.get(url, {"project": self.project1.slug}) assert response.status_code == 200 From 5870536f92b4ab5392a9f9aa03f473917689d16d Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:47:07 -0700 Subject: [PATCH 09/10] docs(api): Mark release project_id filter deprecated Keep project_id documented as a deprecated release details filter and point callers to the project ID-or-slug alias. Co-Authored-By: OpenCode --- .../releases/endpoints/organization_release_details.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index d523127a83fe..e07987980cb9 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -319,7 +319,14 @@ class OrganizationReleaseDetailsEndpoint( parameters=[ GlobalParams.ORG_ID_OR_SLUG, ReleaseParams.VERSION, - ReleaseParams.PROJECT_ID, + OpenApiParameter( + name="project_id", + location="query", + required=False, + type=str, + deprecated=True, + description="Deprecated. Use project instead.", + ), OpenApiParameter( name="project", location="query", From 18b066033a14533089fe67ee21069c5ea322a7f5 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:32:50 -0700 Subject: [PATCH 10/10] fix(api): Scope release project_id navigation Pass the validated release project filter into adjacent release queries so the legacy project_id alias does not broaden next and previous release navigation. Co-Authored-By: OpenCode --- .../endpoints/organization_release_details.py | 11 +++-- .../test_organization_release_details.py | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index e07987980cb9..c86d2d249a35 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -383,14 +383,14 @@ def get( # Validate project access when a project identifier is provided. project = None + project_ids: set[int] | None = None + project_slugs: set[str] | None = None if project_id: try: project_id_or_slug = ProjectIdOrSlugField().run_validation(project_id) except ValidationError: raise ParseError(detail="Invalid project") - project_ids: set[int] | None = None - project_slugs: set[str] | None = None if isinstance(project_id_or_slug, int): if project_id_or_slug == ALL_ACCESS_PROJECT_ID: raise ParseError(detail="Invalid project") @@ -429,7 +429,12 @@ def get( # Get prev and next release to current release try: - filter_params = self.get_filter_params(request, organization) + filter_params = self.get_filter_params( + request, + organization, + project_ids=project_ids, + project_slugs=project_slugs, + ) current_project_meta.update( { **self.get_adjacent_releases_to_current_release( diff --git a/tests/sentry/releases/endpoints/test_organization_release_details.py b/tests/sentry/releases/endpoints/test_organization_release_details.py index 09cd45ec4a07..10c3b0593c87 100644 --- a/tests/sentry/releases/endpoints/test_organization_release_details.py +++ b/tests/sentry/releases/endpoints/test_organization_release_details.py @@ -314,6 +314,48 @@ def test_get_prev_and_next_release_to_current_release_on_date_sort(self) -> None assert response.data["currentProjectMeta"]["nextReleaseVersion"] == "foobar@2.0.0" assert response.data["currentProjectMeta"]["prevReleaseVersion"] is None + def test_project_id_scopes_adjacent_releases(self) -> None: + other_project = self.create_project(teams=[self.team1], organization=self.organization) + now = datetime.now(UTC) + + other_newer_release = Release.objects.create( + date_added=now, + organization_id=self.organization.id, + version="other@2.0.0", + ) + other_newer_release.add_project(other_project) + + current_release = Release.objects.create( + date_added=now - timedelta(days=1), + organization_id=self.organization.id, + version="project@2.0.0", + ) + current_release.add_project(self.project1) + + previous_release = Release.objects.create( + date_added=now - timedelta(days=2), + organization_id=self.organization.id, + version="project@1.0.0", + ) + previous_release.add_project(self.project1) + + self.create_member(teams=[self.team1], user=self.user1, organization=self.organization) + self.login_as(user=self.user1) + + url = reverse( + "sentry-api-0-organization-release-details", + kwargs={ + "organization_id_or_slug": self.organization.slug, + "version": current_release.version, + }, + ) + + response = self.client.get(url, {"project_id": self.project1.id}) + + assert response.status_code == 200 + assert response.data["currentProjectMeta"]["nextReleaseVersion"] is None + assert response.data["currentProjectMeta"]["prevReleaseVersion"] == previous_release.version + def test_get_prev_and_next_release_to_current_release_on_date_sort_with_same_date(self) -> None: """ Test that ensures that in the case we are trying to get prev and next release to a current