Skip to content

Commit 7eeecfb

Browse files
feat(snapshots): Add snapshots list table to Releases page (#112819)
<img width="3862" height="3064" alt="CleanShot 2026-04-13 at 10 24 38@2x" src="https://github.com/user-attachments/assets/83079ec4-a370-4673-baea-88a78d9cd057" /> Adds a new Snapshots tab to the Releases page for browsing visual snapshot builds. The tab is gated behind the `organizations:preprod-snapshots` feature flag, independently from the existing Mobile Builds tab (`preprod-frontend-routes`). **What this adds:** - `PreprodBuildsSnapshotTable` component with a consolidated column layout inspired by the old Emerge Tools UI: - **Snapshot**: app name + image count subtitle - **Changes**: shows lifecycle state tags (Base/Pending/Processing/Failed) when comparison isn't complete, or full diff summary text (e.g., "10 modified, 1 unchanged") when it is - **Branch**: git ref + commit SHA - **Approval**: only shows Approved/Needs Approval badges for successful comparisons - **Created**: relative timestamp - Backend additions to `SnapshotInfo` API response: - `comparison_error_message` field for rich Failed state tooltips - Literal types for `comparison_state` and `approval_status` for type safety on both sides - Fixed N+1 query issue in `to_snapshot_info()` by adding `select_related`/`prefetch_related` for snapshot metrics, comparisons, and approvals in the builds endpoint queryset - Snapshots tab visibility independently gated from Mobile Builds tab, with proper fallback if a user navigates to a tab whose feature flag is off --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cbeebed commit 7eeecfb

File tree

12 files changed

+480
-42
lines changed

12 files changed

+480
-42
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,16 @@ def on_results(artifacts: list[PreprodArtifact]) -> list[dict[str, Any]]:
7777
try:
7878
queryset = queryset_for_query(query, organization)
7979
queryset = queryset.select_related(
80-
"project", "build_configuration", "commit_comparison", "mobile_app_info"
81-
).prefetch_related("preprodartifactsizemetrics_set")
80+
"project",
81+
"build_configuration",
82+
"commit_comparison",
83+
"mobile_app_info",
84+
"preprodsnapshotmetrics",
85+
).prefetch_related(
86+
"preprodartifactsizemetrics_set",
87+
"preprodsnapshotmetrics__snapshot_comparisons_head_metrics",
88+
"preprodcomparisonapproval_set",
89+
)
8290
queryset = queryset.filter(date_added__gte=cutoff)
8391
if start:
8492
queryset = queryset.filter(date_added__gte=start)

src/sentry/preprod/api/models/project_preprod_build_details_models.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
get_download_count_for_artifact,
1010
is_installable_artifact,
1111
)
12-
from sentry.preprod.models import Platform, PreprodArtifact, PreprodArtifactSizeMetrics
12+
from sentry.preprod.models import (
13+
Platform,
14+
PreprodArtifact,
15+
PreprodArtifactSizeMetrics,
16+
PreprodComparisonApproval,
17+
)
18+
from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics
1319
from sentry.preprod.vcs.status_checks.size.tasks import StatusCheckErrorType
1420

1521
logger = logging.getLogger(__name__)
@@ -83,6 +89,17 @@ class PostedStatusChecks(BaseModel):
8389
size: StatusCheckResult | None = None
8490

8591

92+
class SnapshotComparisonInfo(BaseModel):
93+
image_count: int
94+
comparison_state: Literal["pending", "processing", "success", "failed"] | None = None
95+
comparison_error_message: str | None = None
96+
images_added: int = 0
97+
images_removed: int = 0
98+
images_changed: int = 0
99+
images_unchanged: int = 0
100+
approval_status: Literal["approved", "requires_approval"] | None = None
101+
102+
86103
class SizeInfoSizeMetric(BaseModel):
87104
metrics_artifact_type: PreprodArtifactSizeMetrics.MetricsArtifactType
88105
install_size_bytes: int
@@ -147,6 +164,7 @@ class BuildDetailsApiResponse(BaseModel):
147164
posted_status_checks: PostedStatusChecks | None = None
148165
base_artifact_id: str | None = None
149166
base_build_info: BuildDetailsAppInfo | None = None
167+
snapshot_comparison_info: SnapshotComparisonInfo | None = None
150168

151169

152170
def create_build_details_app_info(artifact: PreprodArtifact) -> BuildDetailsAppInfo:
@@ -262,6 +280,61 @@ def to_size_info(
262280
raise ValueError(f"Unknown SizeAnalysisState {main_metric.state}")
263281

264282

283+
def to_snapshot_comparison_info(head_artifact: PreprodArtifact) -> SnapshotComparisonInfo | None:
284+
try:
285+
snapshot_metrics = head_artifact.preprodsnapshotmetrics
286+
except PreprodSnapshotMetrics.DoesNotExist:
287+
return None
288+
289+
comparison_state = None
290+
comparison_error_message = None
291+
images_added = 0
292+
images_removed = 0
293+
images_changed = 0
294+
images_unchanged = 0
295+
296+
comparisons = sorted(
297+
snapshot_metrics.snapshot_comparisons_head_metrics.all(),
298+
key=lambda c: c.id,
299+
reverse=True,
300+
)
301+
comparison = comparisons[0] if comparisons else None
302+
if comparison:
303+
comparison_state = PreprodSnapshotComparison.State(comparison.state).name.lower()
304+
if comparison.state == PreprodSnapshotComparison.State.SUCCESS:
305+
images_added = comparison.images_added
306+
images_removed = comparison.images_removed
307+
images_changed = comparison.images_changed
308+
images_unchanged = comparison.images_unchanged
309+
elif comparison.state == PreprodSnapshotComparison.State.FAILED:
310+
comparison_error_message = comparison.error_message
311+
312+
approval_status = None
313+
# REJECTED is no longer used; all non-APPROVED statuses are treated as requires_approval
314+
approvals = [
315+
a
316+
for a in head_artifact.preprodcomparisonapproval_set.all()
317+
if a.preprod_feature_type == PreprodComparisonApproval.FeatureType.SNAPSHOTS
318+
]
319+
approvals.sort(key=lambda a: a.id, reverse=True)
320+
if approvals:
321+
if approvals[0].approval_status == PreprodComparisonApproval.ApprovalStatus.APPROVED:
322+
approval_status = "approved"
323+
else:
324+
approval_status = "requires_approval"
325+
326+
return SnapshotComparisonInfo(
327+
image_count=snapshot_metrics.image_count,
328+
comparison_state=comparison_state,
329+
comparison_error_message=comparison_error_message,
330+
images_added=images_added,
331+
images_removed=images_removed,
332+
images_changed=images_changed,
333+
images_unchanged=images_unchanged,
334+
approval_status=approval_status,
335+
)
336+
337+
265338
def transform_preprod_artifact_to_build_details(
266339
artifact: PreprodArtifact,
267340
) -> BuildDetailsApiResponse:
@@ -315,6 +388,8 @@ def transform_preprod_artifact_to_build_details(
315388

316389
posted_status_checks = _parse_posted_status_checks(artifact)
317390

391+
snapshot_comparison_info = to_snapshot_comparison_info(artifact)
392+
318393
return BuildDetailsApiResponse(
319394
id=artifact.id,
320395
state=artifact.state,
@@ -327,6 +402,7 @@ def transform_preprod_artifact_to_build_details(
327402
posted_status_checks=posted_status_checks,
328403
base_artifact_id=base_artifact.id if base_artifact else None,
329404
base_build_info=base_build_info,
405+
snapshot_comparison_info=snapshot_comparison_info,
330406
)
331407

332408

static/app/components/preprod/preprodBuildsDisplay.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum PreprodBuildsDisplay {
22
SIZE = 'size',
33
DISTRIBUTION = 'distribution',
4+
SNAPSHOT = 'snapshot',
45
}
56

67
export function getPreprodBuildsDisplay(
@@ -13,6 +14,8 @@ export function getPreprodBuildsDisplay(
1314
switch (display) {
1415
case PreprodBuildsDisplay.DISTRIBUTION:
1516
return PreprodBuildsDisplay.DISTRIBUTION;
17+
case PreprodBuildsDisplay.SNAPSHOT:
18+
return PreprodBuildsDisplay.SNAPSHOT;
1619
case PreprodBuildsDisplay.SIZE:
1720
default:
1821
return PreprodBuildsDisplay.SIZE;

static/app/components/preprod/preprodBuildsSearchControls.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ interface PreprodBuildsSearchControlsProps {
3535
* are shown.
3636
*/
3737
allowedKeys?: string[];
38+
/**
39+
* Hide the display mode toggle
40+
*/
41+
hideDisplayToggle?: boolean;
3842
/**
3943
* Called on every keystroke (for controlled input with debounce)
4044
*/
@@ -54,6 +58,7 @@ export function PreprodBuildsSearchControls({
5458
display,
5559
projects,
5660
allowedKeys = MOBILE_BUILDS_ALLOWED_KEYS,
61+
hideDisplayToggle,
5762
onChange,
5863
onSearch,
5964
onDisplayChange,
@@ -74,20 +79,22 @@ export function PreprodBuildsSearchControls({
7479
projects={projects}
7580
/>
7681
</Container>
77-
<Container maxWidth="200px">
78-
<CompactSelect
79-
options={displaySelectOptions}
80-
value={display}
81-
onChange={option => onDisplayChange(option.value)}
82-
trigger={triggerProps => (
83-
<OverlayTrigger.Button
84-
{...triggerProps}
85-
prefix={t('Display')}
86-
style={{width: '100%', zIndex: 1}}
87-
/>
88-
)}
89-
/>
90-
</Container>
82+
{!hideDisplayToggle && (
83+
<Container maxWidth="200px">
84+
<CompactSelect
85+
options={displaySelectOptions}
86+
value={display}
87+
onChange={option => onDisplayChange(option.value)}
88+
trigger={triggerProps => (
89+
<OverlayTrigger.Button
90+
{...triggerProps}
91+
prefix={t('Display')}
92+
style={{width: '100%', zIndex: 1}}
93+
/>
94+
)}
95+
/>
96+
</Container>
97+
)}
9198
</Flex>
9299
);
93100
}

0 commit comments

Comments
 (0)