Skip to content

Commit 19fa980

Browse files
grichaOpenCode
andauthored
feat(api): Accept project slugs in release endpoints (#117493)
Release threshold create/status endpoints and organization release details now accept project IDs or slugs through the canonical `project` value while preserving existing `projectSlug` runtime and schema compatibility where that parameter already exists. Single-project release detail lookup rejects all-project sentinels and validates project access before filtering. This is the releases split from the project ID-or-slug migration stack and targets the resolver branch so it can be reviewed independently. Validated with focused release endpoint tests, targeted ruff/mypy, and `make test-api-docs`. --------- Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent 111bd0b commit 19fa980

8 files changed

Lines changed: 268 additions & 36 deletions

File tree

src/sentry/api/bases/organization.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -394,16 +394,12 @@ def get_projects(
394394
:return: A list of Project objects, or raises PermissionDenied. When project_ids or project_slugs
395395
are explicitly provided, the returned list is guaranteed non-empty (or PermissionDenied is raised).
396396
397-
NOTE: Passing both project_ids and project_slugs raises ``ParseError``.
398397
"""
399398
qs = Project.objects.filter(organization_id=organization.id, status=ObjectStatus.ACTIVE)
400-
if project_slugs and project_ids:
401-
raise ParseError(detail="Cannot query for both ids and slugs")
402-
403-
if project_ids:
404-
requested_projects = ParsedProjectIdOrSlugParams(ids=project_ids, slugs=set())
405-
elif project_slugs:
406-
requested_projects = ParsedProjectIdOrSlugParams(ids=set(), slugs=set(project_slugs))
399+
if project_ids or project_slugs:
400+
requested_projects = ParsedProjectIdOrSlugParams(
401+
ids=project_ids or set(), slugs=set(project_slugs or ())
402+
)
407403
else:
408404
requested_projects = self.get_requested_project_params_unchecked(request)
409405
ids = requested_projects.ids

src/sentry/api/endpoints/release_thresholds/release_threshold_index.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,29 @@
1010
from sentry.api.api_publish_status import ApiPublishStatus
1111
from sentry.api.base import cell_silo_endpoint
1212
from sentry.api.bases.organization import OrganizationEndpoint
13+
from sentry.api.helpers.projects import ProjectIdOrSlug, ProjectIdOrSlugField
1314
from sentry.api.paginator import OffsetPaginator
1415
from sentry.api.serializers import serialize
16+
from sentry.constants import ALL_ACCESS_PROJECTS_SLUG
1517
from sentry.models.organization import Organization
1618
from sentry.models.release_threshold.release_threshold import ReleaseThreshold
1719

1820

1921
class ReleaseThresholdIndexGETData(TypedDict, total=False):
2022
environment: list[str]
21-
project: list[int]
23+
project: list[ProjectIdOrSlug]
2224

2325

2426
class ReleaseThresholdIndexGETValidator(serializers.Serializer[ReleaseThresholdIndexGETData]):
2527
environment = serializers.ListField(
2628
required=False, allow_empty=True, child=serializers.CharField()
2729
)
28-
project = serializers.ListField(
29-
required=True, allow_empty=False, child=serializers.IntegerField()
30-
)
30+
project = serializers.ListField(required=True, allow_empty=False, child=ProjectIdOrSlugField())
31+
32+
def validate_project(self, value: list[ProjectIdOrSlug]) -> list[ProjectIdOrSlug]:
33+
if ALL_ACCESS_PROJECTS_SLUG in value:
34+
raise serializers.ValidationError("Invalid project")
35+
return value
3136

3237

3338
@cell_silo_endpoint
@@ -39,7 +44,7 @@ class ReleaseThresholdIndexEndpoint(OrganizationEndpoint):
3944

4045
def get(self, request: Request, organization: Organization) -> HttpResponse:
4146
validator = ReleaseThresholdIndexGETValidator(
42-
data=request.query_params,
47+
data=self.get_query_params_without_empty_project_params(request),
4348
)
4449
if not validator.is_valid():
4550
return Response(validator.errors, status=400)

src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
get_errors_counts_timeseries_by_project_and_release,
2929
get_new_issue_counts,
3030
)
31+
from sentry.api.helpers.projects import (
32+
ProjectIdOrSlug,
33+
ProjectIdOrSlugField,
34+
parse_id_or_slug_params,
35+
)
3136
from sentry.api.serializers import serialize
3237
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST
3338
from sentry.apidocs.examples.release_threshold_examples import ReleaseThresholdExamples
@@ -56,6 +61,7 @@ class ReleaseThresholdStatusIndexData(TypedDict, total=False):
5661
environment: list[str]
5762
projectSlug: list[str]
5863
release: list[str]
64+
project: list[ProjectIdOrSlug]
5965

6066

6167
class ReleaseThresholdStatusIndexSerializer(
@@ -82,7 +88,7 @@ class ReleaseThresholdStatusIndexSerializer(
8288
projectSlug = serializers.ListField(
8389
required=False,
8490
allow_empty=True,
85-
child=serializers.CharField(),
91+
child=serializers.CharField(allow_blank=True),
8692
help_text=("A list of project slugs to filter your results by."),
8793
)
8894
release = serializers.ListField(
@@ -91,6 +97,12 @@ class ReleaseThresholdStatusIndexSerializer(
9197
child=serializers.CharField(),
9298
help_text=("A list of release versions to filter your results by."),
9399
)
100+
project = serializers.ListField(
101+
required=False,
102+
allow_empty=True,
103+
child=ProjectIdOrSlugField(),
104+
help_text=("A list of project IDs or slugs to filter your results by."),
105+
)
94106

95107
def validate(self, data: ReleaseThresholdStatusIndexData) -> ReleaseThresholdStatusIndexData:
96108
if data["start"] >= data["end"]:
@@ -136,27 +148,36 @@ def get(
136148
# NOTE: start/end parameters determine window to query for releases
137149
# This is NOT the window to query snuba for event data - nor the individual threshold windows
138150
# ========================================================================
139-
serializer = ReleaseThresholdStatusIndexSerializer(
140-
data=request.query_params,
141-
)
151+
query_params = self.get_query_params_with_project_slug_precedence(request)
152+
153+
serializer = ReleaseThresholdStatusIndexSerializer(data=query_params)
142154
if not serializer.is_valid():
143155
return Response(as_validation_errors(serializer), status=400)
144156

145-
environments_list = serializer.validated_data.get(
146-
"environment"
147-
) # list of environment names
148-
project_slug_list = serializer.validated_data.get("projectSlug")
149-
releases_list = serializer.validated_data.get("release") # list of release versions
157+
validated_data = serializer.validated_data
158+
environments_list = validated_data.get("environment") # list of environment names
159+
releases_list = validated_data.get("release") # list of release versions
160+
161+
project_ids: set[int] | None = None
162+
project_slugs = {slug for slug in validated_data.get("projectSlug", []) if slug} or None
163+
if project_slugs is None:
164+
requested_project = parse_id_or_slug_params(validated_data.get("project", []))
165+
project_ids = requested_project.ids or None
166+
project_slugs = requested_project.slugs or None
167+
150168
try:
151169
filter_params = self.get_filter_params(
152-
request, organization, date_filter_optional=True, project_slugs=project_slug_list
170+
request,
171+
organization,
172+
date_filter_optional=True,
173+
project_ids=project_ids,
174+
project_slugs=project_slugs,
153175
)
154176
except NoProjects:
155177
raise NoProjects("No projects available")
156178

157-
# Use validated project IDs from get_filter_params instead of raw user input.
158-
# The raw project_slug_list could contain slugs for projects the user doesn't
159-
# have access to, bypassing the permission checks in get_projects().
179+
# Use project IDs from get_filter_params instead of raw project filters so
180+
# project access is checked before fetching threshold data.
160181
validated_project_ids = set(filter_params["project_id"])
161182

162183
start: datetime | None = filter_params["start"]
@@ -203,7 +224,7 @@ def get(
203224
"Fetched releases",
204225
extra={
205226
"results": len(queryset),
206-
"project_slugs": project_slug_list,
227+
"project_slugs": project_slugs,
207228
"releases": releases_list,
208229
"environments": environments_list,
209230
},

src/sentry/releases/endpoints/organization_release_details.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sentry_sdk
22
from django.db.models import Q
3-
from drf_spectacular.utils import extend_schema, extend_schema_serializer
4-
from rest_framework.exceptions import ParseError
3+
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_serializer
4+
from rest_framework.exceptions import ParseError, ValidationError
55
from rest_framework.request import Request
66
from rest_framework.response import Response
77
from rest_framework.serializers import ListField
@@ -17,6 +17,10 @@
1717
get_stats_period_detail,
1818
)
1919
from sentry.api.exceptions import ConflictError, InvalidRepository, ResourceDoesNotExist
20+
from sentry.api.helpers.projects import (
21+
PROJECT_ID_OR_SLUG_SCHEMA,
22+
ProjectIdOrSlugField,
23+
)
2024
from sentry.api.serializers import serialize
2125
from sentry.api.serializers.rest_framework import (
2226
ReleaseHeadCommitSerializer,
@@ -38,6 +42,7 @@
3842
as_validation_errors,
3943
)
4044
from sentry.apidocs.utils import inline_sentry_response_serializer
45+
from sentry.constants import ALL_ACCESS_PROJECT_ID, ALL_ACCESS_PROJECTS_SLUG
4146
from sentry.models.activity import Activity
4247
from sentry.models.organization import Organization
4348
from sentry.models.release import Release, ReleaseStatus
@@ -314,7 +319,21 @@ class OrganizationReleaseDetailsEndpoint(
314319
parameters=[
315320
GlobalParams.ORG_ID_OR_SLUG,
316321
ReleaseParams.VERSION,
317-
ReleaseParams.PROJECT_ID,
322+
OpenApiParameter(
323+
name="project_id",
324+
location="query",
325+
required=False,
326+
type=str,
327+
deprecated=True,
328+
description="Deprecated. Use project instead.",
329+
),
330+
OpenApiParameter(
331+
name="project",
332+
location="query",
333+
required=False,
334+
type=PROJECT_ID_OR_SLUG_SCHEMA,
335+
description="The project ID or slug to filter by. Overrides project_id when both are sent.",
336+
),
318337
ReleaseParams.HEALTH,
319338
ReleaseParams.ADOPTION_STAGES,
320339
ReleaseParams.SUMMARY_STATS_PERIOD,
@@ -340,7 +359,7 @@ def get(
340359
"""
341360
# Dictionary responsible for storing selected project meta data
342361
current_project_meta = {}
343-
project_id = request.GET.get("project")
362+
project_id = request.GET.get("project") or request.GET.get("project_id")
344363
with_health = request.GET.get("health") == "1"
345364
with_adoption_stages = request.GET.get("adoptionStages") == "1"
346365
summary_stats_period = request.GET.get("summaryStatsPeriod") or "14d"
@@ -362,15 +381,30 @@ def get(
362381
if not self.has_release_permission(request, organization, release):
363382
raise ResourceDoesNotExist
364383

365-
# Validate project access when project_id is provided
384+
# Validate project access when a project identifier is provided.
366385
project = None
386+
project_ids: set[int] | None = None
387+
project_slugs: set[str] | None = None
367388
if project_id:
368389
try:
369-
project_id_int = int(project_id)
370-
except ValueError:
390+
project_id_or_slug = ProjectIdOrSlugField().run_validation(project_id)
391+
except ValidationError:
371392
raise ParseError(detail="Invalid project")
393+
394+
if isinstance(project_id_or_slug, int):
395+
if project_id_or_slug == ALL_ACCESS_PROJECT_ID:
396+
raise ParseError(detail="Invalid project")
397+
project_ids = {project_id_or_slug}
398+
else:
399+
if project_id_or_slug == ALL_ACCESS_PROJECTS_SLUG:
400+
raise ParseError(detail="Invalid project")
401+
project_slugs = {project_id_or_slug}
402+
372403
validated_projects = self.get_projects(
373-
request, organization, project_ids={project_id_int}
404+
request,
405+
organization,
406+
project_ids=project_ids,
407+
project_slugs=project_slugs,
374408
)
375409
if not validated_projects:
376410
raise ResourceDoesNotExist
@@ -395,7 +429,12 @@ def get(
395429

396430
# Get prev and next release to current release
397431
try:
398-
filter_params = self.get_filter_params(request, organization)
432+
filter_params = self.get_filter_params(
433+
request,
434+
organization,
435+
project_ids=project_ids,
436+
project_slugs=project_slugs,
437+
)
399438
current_project_meta.update(
400439
{
401440
**self.get_adjacent_releases_to_current_release(

tests/sentry/api/bases/test_organization.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,19 @@ def test_project_param_with_mixed_ids_and_slugs(self) -> None:
666666

667667
assert {p.id for p in result} == {self.project_1.id, self.project_2.id}
668668

669+
def test_explicit_project_ids_and_slugs(self) -> None:
670+
self.create_team_membership(user=self.user, team=self.team_3)
671+
request = self.build_request()
672+
673+
result = self.endpoint.get_projects(
674+
request,
675+
self.org,
676+
project_ids={self.project_1.id},
677+
project_slugs={self.project_2.slug},
678+
)
679+
680+
assert {p.id for p in result} == {self.project_1.id, self.project_2.id}
681+
669682
def test_project_param_with_nonexistent_slug(self) -> None:
670683
self.create_team_membership(user=self.user, team=self.team_1)
671684
request = self.build_request(project=["nonexistent-slug"])

tests/sentry/api/endpoints/release_thresholds/test_release_threshold_status.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,64 @@ def test_get_success_project_slug_filter(self) -> None:
407407
r2_keys = [k for k, v in data.items() if k.split("-")[1] == self.release2.version]
408408
assert len(r2_keys) == 0
409409

410+
def test_get_success_project_param_slug_filter(self) -> None:
411+
now = datetime.now(UTC)
412+
yesterday = now - timedelta(hours=24)
413+
414+
project_slug_response = self.get_success_response(
415+
self.organization.slug, start=yesterday, end=now, projectSlug=[self.project2.slug]
416+
)
417+
project_response = self.get_success_response(
418+
self.organization.slug, start=yesterday, end=now, project=[self.project2.slug]
419+
)
420+
421+
assert project_response.data == project_slug_response.data
422+
423+
def test_get_success_project_param_mixed_id_and_slug_filter(self) -> None:
424+
now = datetime.now(UTC)
425+
yesterday = now - timedelta(hours=24)
426+
427+
response = self.get_success_response(
428+
self.organization.slug,
429+
start=yesterday,
430+
end=now,
431+
project=[str(self.project1.id), self.project2.slug],
432+
)
433+
434+
assert set(response.data.keys()) == {
435+
f"{self.project1.slug}-{self.release1.version}",
436+
f"{self.project1.slug}-{self.release2.version}",
437+
f"{self.project2.slug}-{self.release1.version}",
438+
}
439+
440+
def test_get_success_project_slug_takes_precedence_over_project_id(self) -> None:
441+
now = datetime.now(UTC)
442+
yesterday = now - timedelta(hours=24)
443+
444+
response = self.get_success_response(
445+
self.organization.slug,
446+
start=yesterday,
447+
end=now,
448+
projectSlug=[self.project2.slug],
449+
project=[str(self.project1.id)],
450+
)
451+
452+
assert set(response.data.keys()) == {f"{self.project2.slug}-{self.release1.version}"}
453+
454+
def test_get_success_empty_project_slug_falls_back_to_project_filter(self) -> None:
455+
now = datetime.now(UTC)
456+
yesterday = now - timedelta(hours=24)
457+
458+
response = self.get_success_response(
459+
self.organization.slug,
460+
start=yesterday,
461+
end=now,
462+
projectSlug=[""],
463+
project=[self.project2.slug],
464+
)
465+
466+
assert set(response.data.keys()) == {f"{self.project2.slug}-{self.release1.version}"}
467+
410468
@patch(
411469
"sentry.api.endpoints.release_thresholds.release_threshold_status_index.fetch_sessions_data"
412470
)

tests/sentry/api/endpoints/release_thresholds/test_release_thresholds_index.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def setUp(self) -> None:
2222
def test_get_invalid_project(self) -> None:
2323
self.get_error_response(self.organization.slug, project="foo bar")
2424

25+
def test_get_all_projects_slug_is_invalid(self) -> None:
26+
response = self.get_error_response(self.organization.slug, project="$all")
27+
28+
assert response.status_code == 400
29+
2530
def test_get_no_project(self) -> None:
2631
self.get_error_response(self.organization.slug)
2732

@@ -49,6 +54,21 @@ def test_get_valid_project(self) -> None:
4954
assert created_threshold["environment"]["id"] == str(self.canary_environment.id)
5055
assert created_threshold["environment"]["name"] == self.canary_environment.name
5156

57+
def test_get_valid_project_slug(self) -> None:
58+
ReleaseThreshold.objects.create(
59+
threshold_type=0,
60+
trigger_type=0,
61+
value=100,
62+
window_in_seconds=1800,
63+
project=self.project,
64+
environment=self.canary_environment,
65+
)
66+
67+
response = self.get_success_response(self.organization.slug, project=self.project.slug)
68+
69+
assert len(response.data) == 1
70+
assert response.data[0]["project"]["id"] == str(self.project.id)
71+
5272
def test_get_invalid_environment(self) -> None:
5373
self.get_error_response(self.organization.slug, environment="foo bar", project="-1")
5474

0 commit comments

Comments
 (0)