Skip to content

Commit 5602945

Browse files
NicoHinderlingclaude
authored andcommitted
feat(preprod): Add snapshot auto-approval for repeated PR builds (#112421)
Closes EME-976 Adds auto-approval logic for snapshot comparisons. When a snapshot build on a PR is approved and a subsequent build is uploaded for the same PR (e.g. after a rebase), the new build is automatically approved if its "interesting" images (changed, added, removed, errored, renamed) match the previously approved build — same image names, same categories, and same content hashes. This addresses a pain point where rebasing a PR would pull in new unchanged images from main, causing the snapshot comparison to lose its approval even though the PR's actual visual changes hadn't changed. Users were forced to re-approve identical snapshots after every rebase. The old Emerge implementation required an exact one-to-one match of ALL images between builds, which broke on rebases. The new approach only compares the non-unchanged subset, so new unchanged images from main don't affect auto-approval. Content hashes are still verified to ensure the PR's actual visual output hasn't changed. The matching scopes to the same project, app_id, build_configuration, PR number, and repo name to prevent cross-build false matches. how the UI looks for an auto-approved snapshot <img width="1726" height="1744" alt="CleanShot 2026-04-07 at 15 45 02@2x" src="https://github.com/user-attachments/assets/e9a01fc0-5a0f-47ef-880d-a4c69f963be2" /> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 613c27c commit 5602945

File tree

6 files changed

+678
-4
lines changed

6 files changed

+678
-4
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,11 @@ def get(self, request: Request, organization: Organization, snapshot_id: str) ->
394394
source="github",
395395
)
396396
)
397+
is_auto_approved = any((a.extras or {}).get("auto_approval") is True for a in approved)
397398
approval_info = SnapshotApprovalInfo(
398399
status="approved",
399400
approvers=approver_list,
401+
is_auto_approved=is_auto_approved,
400402
)
401403
elif all_approvals:
402404
# If records exist but none are APPROVED, they must be NEEDS_APPROVAL

src/sentry/preprod/api/models/snapshots/project_preprod_snapshot_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class SnapshotApprover(BaseModel):
5959
class SnapshotApprovalInfo(BaseModel):
6060
status: Literal["approved", "requires_approval"]
6161
approvers: list[SnapshotApprover] = []
62+
is_auto_approved: bool = False
6263

6364

6465
class SnapshotDetailsApiResponse(BaseModel):

src/sentry/preprod/snapshots/tasks.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
import orjson
88
from django.db import IntegrityError
99
from django.utils import timezone
10+
from objectstore_client import Session
1011
from objectstore_client.client import RequestError
1112
from pydantic import ValidationError
1213
from taskbroker_client.retry import Retry
1314

1415
from sentry.objectstore import get_preprod_session
15-
from sentry.preprod.models import PreprodArtifact
16+
from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval
1617
from sentry.preprod.snapshots.image_diff.compare import compare_images_batch
1718
from sentry.preprod.snapshots.image_diff.odiff import OdiffServer
1819
from sentry.preprod.snapshots.manifest import (
@@ -113,6 +114,128 @@ def _create_pixel_batches(
113114
return batches
114115

115116

117+
class ImageFingerprint(NamedTuple):
118+
name: str
119+
status: str
120+
head_hash: str | None = None
121+
previous_image_file_name: str | None = None
122+
123+
124+
def _build_comparison_fingerprints(manifest: ComparisonManifest) -> set[ImageFingerprint]:
125+
fingerprints: set[ImageFingerprint] = set()
126+
for name, image in manifest.images.items():
127+
if image.status == "unchanged":
128+
continue
129+
if image.status in ("changed", "added"):
130+
if not image.head_hash:
131+
continue
132+
fingerprints.add(ImageFingerprint(name, image.status, image.head_hash))
133+
elif image.status == "renamed":
134+
if not image.head_hash or not image.previous_image_file_name:
135+
continue
136+
fingerprints.add(
137+
ImageFingerprint(name, "renamed", image.head_hash, image.previous_image_file_name)
138+
)
139+
else:
140+
fingerprints.add(ImageFingerprint(name, image.status))
141+
return fingerprints
142+
143+
144+
def _try_auto_approve_snapshot(
145+
head_artifact: PreprodArtifact,
146+
comparison_manifest: ComparisonManifest,
147+
session: Session,
148+
) -> None:
149+
cc = head_artifact.commit_comparison
150+
if not cc or not cc.pr_number or not cc.head_repo_name:
151+
return
152+
153+
head_fingerprints = _build_comparison_fingerprints(comparison_manifest)
154+
if not head_fingerprints:
155+
return
156+
157+
approved_sibling = (
158+
PreprodArtifact.objects.filter(
159+
project_id=head_artifact.project_id,
160+
app_id=head_artifact.app_id,
161+
build_configuration=head_artifact.build_configuration,
162+
commit_comparison__pr_number=cc.pr_number,
163+
commit_comparison__head_repo_name=cc.head_repo_name,
164+
preprodcomparisonapproval__preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS,
165+
preprodcomparisonapproval__approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED,
166+
preprodsnapshotmetrics__snapshot_comparisons_head_metrics__state=PreprodSnapshotComparison.State.SUCCESS,
167+
)
168+
.exclude(id=head_artifact.id)
169+
.order_by("-date_added")
170+
.first()
171+
)
172+
173+
if not approved_sibling:
174+
return
175+
176+
sibling_comparison = (
177+
PreprodSnapshotComparison.objects.filter(
178+
head_snapshot_metrics__preprod_artifact=approved_sibling,
179+
state=PreprodSnapshotComparison.State.SUCCESS,
180+
)
181+
.order_by("-date_updated")
182+
.first()
183+
)
184+
185+
if not sibling_comparison:
186+
return
187+
188+
sibling_comparison_key = (sibling_comparison.extras or {}).get("comparison_key")
189+
if not sibling_comparison_key:
190+
return
191+
192+
try:
193+
sibling_manifest = ComparisonManifest(
194+
**orjson.loads(session.get(sibling_comparison_key).payload.read())
195+
)
196+
except Exception:
197+
logger.exception(
198+
"auto_approve: failed to load sibling comparison manifest",
199+
extra={
200+
"head_artifact_id": head_artifact.id,
201+
"sibling_artifact_id": approved_sibling.id,
202+
"comparison_key": sibling_comparison_key,
203+
},
204+
)
205+
return
206+
207+
sibling_fingerprints = _build_comparison_fingerprints(sibling_manifest)
208+
209+
if head_fingerprints != sibling_fingerprints:
210+
logger.info(
211+
"auto_approve: fingerprints do not match",
212+
extra={
213+
"head_artifact_id": head_artifact.id,
214+
"sibling_artifact_id": approved_sibling.id,
215+
},
216+
)
217+
return
218+
219+
PreprodComparisonApproval.objects.create(
220+
preprod_artifact=head_artifact,
221+
preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS,
222+
approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED,
223+
approved_at=timezone.now(),
224+
extras={
225+
"auto_approval": True,
226+
"prev_approved_artifact_id": approved_sibling.id,
227+
},
228+
)
229+
230+
logger.info(
231+
"auto_approve: snapshot auto-approved",
232+
extra={
233+
"head_artifact_id": head_artifact.id,
234+
"prev_approved_artifact_id": approved_sibling.id,
235+
},
236+
)
237+
238+
116239
@instrumented_task(
117240
name="sentry.preprod.tasks.compare_snapshots",
118241
namespace=preprod_tasks,
@@ -581,6 +704,14 @@ def _fetch_hash(h: str) -> None:
581704
):
582705
metrics.incr("preprod.snapshots.diff.zero_changes", sample_rate=1.0, tags=metric_tags)
583706

707+
try:
708+
_try_auto_approve_snapshot(head_artifact, comparison_manifest, session)
709+
except Exception:
710+
logger.exception(
711+
"Auto-approve failed after successful comparison",
712+
extra={"head_artifact_id": head_artifact_id},
713+
)
714+
584715
create_preprod_snapshot_status_check_task.apply_async(
585716
kwargs={
586717
"preprod_artifact_id": head_artifact_id,

static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {Tag} from '@sentry/scraps/badge';
55
import {Button} from '@sentry/scraps/button';
66
import {Flex} from '@sentry/scraps/layout';
77
import {Text} from '@sentry/scraps/text';
8+
import {Tooltip} from '@sentry/scraps/tooltip';
89

910
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
1011
import {Client} from 'sentry/api';
@@ -15,6 +16,7 @@ import {
1516
IconCheckmark,
1617
IconDelete,
1718
IconEllipsis,
19+
IconInfo,
1820
IconRefresh,
1921
IconThumb,
2022
IconTimer,
@@ -47,6 +49,7 @@ export function SnapshotHeaderActions({
4749
const [isDeleting, setIsDeleting] = useState(false);
4850

4951
const isApproved = data.approval_info?.status === 'approved';
52+
const isAutoApproved = data.approval_info?.is_auto_approved ?? false;
5053
const approvers: AvatarUser[] = (data.approval_info?.approvers ?? []).map((a, i) => ({
5154
id: a.id ?? `approver-${i}`,
5255
name: a.name ?? '',
@@ -148,9 +151,22 @@ export function SnapshotHeaderActions({
148151
{data.approval_info &&
149152
(isApproved ? (
150153
<Flex align="center" gap="xl">
151-
<Tag variant="success" icon={<IconCheckmark />}>
152-
{t('Approved')}
153-
</Tag>
154+
<Flex align="center" gap="xs">
155+
<Tag variant="success" icon={<IconCheckmark />}>
156+
{isAutoApproved ? t('Auto-approved') : t('Approved')}
157+
</Tag>
158+
{isAutoApproved && (
159+
<Tooltip
160+
title={t(
161+
'Automatically approved because the changes match a previously approved build on this PR.'
162+
)}
163+
>
164+
<Flex align="center">
165+
<IconInfo size="sm" />
166+
</Flex>
167+
</Tooltip>
168+
)}
169+
</Flex>
154170
{approvers.length > 0 && (
155171
<AvatarList users={approvers} avatarSize={24} maxVisibleAvatars={2} />
156172
)}

static/app/views/preprod/types/snapshotTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface SnapshotApprover {
3636
export interface SnapshotApprovalInfo {
3737
approvers: SnapshotApprover[];
3838
status: 'approved' | 'requires_approval';
39+
is_auto_approved?: boolean;
3940
}
4041

4142
export interface SnapshotDetailsApiResponse {

0 commit comments

Comments
 (0)