Skip to content

Commit f998b41

Browse files
authored
feat(preprod): Add preprod list-builds endpoint (#97742)
Adds a new `preprodartifacts/list-builds` endpoint for creating a builds list page. Initially this will just be used from #97743 for a standalone build list page (for convenience and basic UI iteration), but later I intend for this to be leveraged from the release page.
1 parent a707415 commit f998b41

File tree

7 files changed

+478
-118
lines changed

7 files changed

+478
-118
lines changed

src/sentry/preprod/analytics.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event):
3636
artifact_id: str
3737

3838

39+
@analytics.eventclass("preprod_artifact.api.list_builds")
40+
class PreprodArtifactApiListBuildsEvent(analytics.Event):
41+
organization_id: int
42+
project_id: int
43+
user_id: int | None = None
44+
45+
3946
class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
4047
type = "preprod_artifact.api.install_details"
4148

@@ -52,4 +59,5 @@ class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
5259
analytics.register(PreprodArtifactApiAssembleGenericEvent)
5360
analytics.register(PreprodArtifactApiSizeAnalysisDownloadEvent)
5461
analytics.register(PreprodArtifactApiGetBuildDetailsEvent)
62+
analytics.register(PreprodArtifactApiListBuildsEvent)
5563
analytics.register(PreprodArtifactApiInstallDetailsEvent)

src/sentry/preprod/api/endpoints/project_preprod_build_details.py

Lines changed: 4 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,9 @@
1010
from sentry.api.bases.project import ProjectEndpoint
1111
from sentry.preprod.analytics import PreprodArtifactApiGetBuildDetailsEvent
1212
from sentry.preprod.api.models.project_preprod_build_details_models import (
13-
BuildDetailsApiResponse,
14-
BuildDetailsAppInfo,
15-
BuildDetailsSizeInfo,
16-
BuildDetailsVcsInfo,
17-
platform_from_artifact_type,
13+
transform_preprod_artifact_to_build_details,
1814
)
19-
from sentry.preprod.build_distribution_utils import is_installable_artifact
20-
from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics
15+
from sentry.preprod.models import PreprodArtifact
2116

2217
logger = logging.getLogger(__name__)
2318

@@ -66,113 +61,5 @@ def get(self, request: Request, project, artifact_id) -> Response:
6661
except PreprodArtifact.DoesNotExist:
6762
return Response({"error": f"Preprod artifact {artifact_id} not found"}, status=404)
6863

69-
try:
70-
size_metrics_qs = PreprodArtifactSizeMetrics.objects.select_related(
71-
"preprod_artifact"
72-
).filter(
73-
preprod_artifact__project=project,
74-
preprod_artifact__id=artifact_id,
75-
)
76-
main_artifact_size_metrics = size_metrics_qs.filter(
77-
metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT
78-
)
79-
if main_artifact_size_metrics.count() == 0:
80-
logger.info("No size analysis results found for preprod artifact %s", artifact_id)
81-
size_info = None
82-
else:
83-
size_metrics = main_artifact_size_metrics.first()
84-
85-
if size_metrics is None:
86-
logger.error(
87-
"No size analysis results found for preprod artifact %s", artifact_id
88-
)
89-
size_info = None
90-
elif (
91-
size_metrics.max_install_size is None or size_metrics.max_download_size is None
92-
):
93-
logger.error(
94-
"Size analysis results found for preprod artifact %s but no max install or download size",
95-
artifact_id,
96-
)
97-
size_info = None
98-
else:
99-
size_info = BuildDetailsSizeInfo(
100-
install_size_bytes=size_metrics.max_install_size,
101-
download_size_bytes=size_metrics.max_download_size,
102-
)
103-
except Exception:
104-
logger.exception(
105-
"Failed to retrieve size analysis results for preprod artifact %s", artifact_id
106-
)
107-
size_info = None
108-
109-
app_info = BuildDetailsAppInfo(
110-
app_id=preprod_artifact.app_id,
111-
name=preprod_artifact.app_name,
112-
version=preprod_artifact.build_version,
113-
build_number=preprod_artifact.build_number,
114-
date_added=(
115-
preprod_artifact.date_added.isoformat() if preprod_artifact.date_added else None
116-
),
117-
date_built=(
118-
preprod_artifact.date_built.isoformat() if preprod_artifact.date_built else None
119-
),
120-
artifact_type=preprod_artifact.artifact_type,
121-
platform=platform_from_artifact_type(preprod_artifact.artifact_type),
122-
is_installable=is_installable_artifact(preprod_artifact),
123-
# TODO: Implement in the future when available
124-
# build_configuration=preprod_artifact.build_configuration.name if preprod_artifact.build_configuration else None,
125-
# icon=None,
126-
)
127-
128-
vcs_info = BuildDetailsVcsInfo(
129-
head_sha=(
130-
preprod_artifact.commit_comparison.head_sha
131-
if preprod_artifact.commit_comparison
132-
else None
133-
),
134-
base_sha=(
135-
preprod_artifact.commit_comparison.base_sha
136-
if preprod_artifact.commit_comparison
137-
else None
138-
),
139-
provider=(
140-
preprod_artifact.commit_comparison.provider
141-
if preprod_artifact.commit_comparison
142-
else None
143-
),
144-
head_repo_name=(
145-
preprod_artifact.commit_comparison.head_repo_name
146-
if preprod_artifact.commit_comparison
147-
else None
148-
),
149-
base_repo_name=(
150-
preprod_artifact.commit_comparison.base_repo_name
151-
if preprod_artifact.commit_comparison
152-
else None
153-
),
154-
head_ref=(
155-
preprod_artifact.commit_comparison.head_ref
156-
if preprod_artifact.commit_comparison
157-
else None
158-
),
159-
base_ref=(
160-
preprod_artifact.commit_comparison.base_ref
161-
if preprod_artifact.commit_comparison
162-
else None
163-
),
164-
pr_number=(
165-
preprod_artifact.commit_comparison.pr_number
166-
if preprod_artifact.commit_comparison
167-
else None
168-
),
169-
)
170-
171-
api_response = BuildDetailsApiResponse(
172-
state=preprod_artifact.state,
173-
app_info=app_info,
174-
vcs_info=vcs_info,
175-
size_info=size_info,
176-
)
177-
178-
return Response(api_response.dict())
64+
build_details = transform_preprod_artifact_to_build_details(preprod_artifact)
65+
return Response(build_details.dict())
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import logging
2+
3+
from rest_framework.request import Request
4+
from rest_framework.response import Response
5+
6+
from sentry import analytics, features
7+
from sentry.api.api_owners import ApiOwner
8+
from sentry.api.api_publish_status import ApiPublishStatus
9+
from sentry.api.base import region_silo_endpoint
10+
from sentry.api.bases.project import ProjectEndpoint
11+
from sentry.api.paginator import OffsetPaginator
12+
from sentry.preprod.analytics import PreprodArtifactApiListBuildsEvent
13+
from sentry.preprod.api.models.project_preprod_build_details_models import (
14+
transform_preprod_artifact_to_build_details,
15+
)
16+
from sentry.preprod.api.models.project_preprod_list_builds_models import (
17+
ListBuildsApiResponse,
18+
PaginationInfo,
19+
)
20+
from sentry.preprod.models import PreprodArtifact
21+
from sentry.utils.cursors import Cursor
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
@region_silo_endpoint
27+
class ProjectPreprodListBuildsEndpoint(ProjectEndpoint):
28+
owner = ApiOwner.EMERGE_TOOLS
29+
publish_status = {
30+
"GET": ApiPublishStatus.EXPERIMENTAL,
31+
}
32+
33+
def get(self, request: Request, project) -> Response:
34+
"""
35+
List preprod builds for a project
36+
````````````````````````````````````````````````````
37+
38+
List preprod builds for a project with optional filtering and pagination.
39+
40+
:pparam string organization_id_or_slug: the id or slug of the organization the
41+
artifacts belong to.
42+
:pparam string project_id_or_slug: the id or slug of the project to retrieve the
43+
artifacts from.
44+
:qparam string app_id: filter by app identifier (e.g., "com.myapp.MyApp")
45+
:qparam string state: filter by artifact state (0=uploading, 1=uploaded, 3=processed, 4=failed)
46+
:qparam string build_version: filter by build version
47+
:qparam string build_configuration: filter by build configuration name
48+
:qparam string platform: filter by platform (ios, android, macos)
49+
:qparam int limit: number of results per page (default 25, max 100)
50+
:qparam int page: page number (default 1)
51+
:auth: required
52+
"""
53+
54+
analytics.record(
55+
PreprodArtifactApiListBuildsEvent(
56+
organization_id=project.organization_id,
57+
project_id=project.id,
58+
user_id=request.user.id,
59+
)
60+
)
61+
62+
if not features.has(
63+
"organizations:preprod-frontend-routes", project.organization, actor=request.user
64+
):
65+
return Response({"error": "Feature not enabled"}, status=403)
66+
67+
queryset = PreprodArtifact.objects.filter(project=project)
68+
69+
app_id = request.GET.get("app_id")
70+
if app_id:
71+
queryset = queryset.filter(app_id__icontains=app_id)
72+
73+
state = request.GET.get("state")
74+
if state:
75+
try:
76+
state_int = int(state)
77+
if state_int in [0, 1, 3, 4]: # Valid states
78+
queryset = queryset.filter(state=state_int)
79+
except ValueError:
80+
pass
81+
82+
build_version = request.GET.get("build_version")
83+
if build_version:
84+
queryset = queryset.filter(build_version__icontains=build_version)
85+
86+
build_configuration = request.GET.get("build_configuration")
87+
if build_configuration:
88+
queryset = queryset.filter(build_configuration__name__icontains=build_configuration)
89+
90+
platform = request.GET.get("platform")
91+
if platform:
92+
# For now, macos artifacts are also XCARCHIVE type
93+
if platform.lower() == "ios" or platform.lower() == "macos":
94+
queryset = queryset.filter(artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE)
95+
elif platform.lower() == "android":
96+
queryset = queryset.filter(
97+
artifact_type__in=[
98+
PreprodArtifact.ArtifactType.AAB,
99+
PreprodArtifact.ArtifactType.APK,
100+
]
101+
)
102+
else:
103+
return Response(
104+
{"error": "Invalid platform: " + platform},
105+
status=400,
106+
)
107+
108+
queryset = queryset.order_by("-date_added")
109+
110+
try:
111+
per_page = min(int(request.GET.get("per_page", 25)), 100)
112+
page = max(int(request.GET.get("page", 1)), 1)
113+
except ValueError:
114+
return Response(
115+
{"error": "Invalid pagination parameters: 'per_page' and 'page' must be integers."},
116+
status=400,
117+
)
118+
119+
# Create paginator
120+
paginator = OffsetPaginator(
121+
queryset,
122+
order_by="-date_added",
123+
max_limit=100,
124+
)
125+
126+
# Create cursor for pagination
127+
# For OffsetPaginator: cursor.offset = page number, cursor.value = limit
128+
# Since we want page 1 to start at offset 0, we need to adjust
129+
indexed_page = page - 1
130+
cursor = Cursor(per_page, indexed_page, False)
131+
132+
# Get paginated results
133+
result = paginator.get_result(
134+
limit=per_page,
135+
cursor=cursor,
136+
count_hits=True,
137+
)
138+
139+
# Transform the results using shared utility
140+
build_details_list = []
141+
for artifact in result.results:
142+
try:
143+
build_details_list.append(transform_preprod_artifact_to_build_details(artifact))
144+
except Exception as e:
145+
logger.warning("Failed to transform artifact %s: %s", artifact.id, str(e))
146+
continue
147+
148+
# Build response with pagination info
149+
response_data = ListBuildsApiResponse(
150+
builds=build_details_list,
151+
pagination=PaginationInfo(
152+
next=result.next.offset if result.next.has_results else None,
153+
prev=result.prev.offset if result.prev.has_results else None,
154+
has_next=result.next.has_results,
155+
has_prev=result.prev.has_results,
156+
page=indexed_page,
157+
per_page=per_page,
158+
total_count=result.hits if result.hits is not None else "unknown",
159+
),
160+
)
161+
162+
return Response(response_data.dict())

src/sentry/preprod/api/endpoints/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .project_preprod_artifact_update import ProjectPreprodArtifactUpdateEndpoint
1414
from .project_preprod_build_details import ProjectPreprodBuildDetailsEndpoint
1515
from .project_preprod_check_for_updates import ProjectPreprodArtifactCheckForUpdatesEndpoint
16+
from .project_preprod_list_builds import ProjectPreprodListBuildsEndpoint
1617

1718
preprod_urlpatterns = [
1819
re_path(
@@ -25,6 +26,11 @@
2526
ProjectPreprodArtifactCheckForUpdatesEndpoint.as_view(),
2627
name="sentry-api-0-project-preprod-check-for-updates",
2728
),
29+
re_path(
30+
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/preprodartifacts/list-builds/$",
31+
ProjectPreprodListBuildsEndpoint.as_view(),
32+
name="sentry-api-0-project-preprod-list-builds",
33+
),
2834
re_path(
2935
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/preprodartifacts/(?P<artifact_id>[^/]+)/size-analysis/$",
3036
ProjectPreprodArtifactSizeAnalysisDownloadEndpoint.as_view(),

0 commit comments

Comments
 (0)