Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,11 @@ def get(self, request: Request, organization: Organization, snapshot_id: str) ->
source="github",
)
)
is_auto_approved = any((a.extras or {}).get("auto_approval") is True for a in approved)
Comment thread
NicoHinderling marked this conversation as resolved.
Comment thread
NicoHinderling marked this conversation as resolved.
approval_info = SnapshotApprovalInfo(
status="approved",
approvers=approver_list,
is_auto_approved=is_auto_approved,
)
elif all_approvals:
# If records exist but none are APPROVED, they must be NEEDS_APPROVAL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class SnapshotApprover(BaseModel):
class SnapshotApprovalInfo(BaseModel):
status: Literal["approved", "requires_approval"]
approvers: list[SnapshotApprover] = []
is_auto_approved: bool = False


class SnapshotDetailsApiResponse(BaseModel):
Expand Down
133 changes: 132 additions & 1 deletion src/sentry/preprod/snapshots/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import orjson
from django.db import IntegrityError
from django.utils import timezone
from objectstore_client import Session
from objectstore_client.client import RequestError
from pydantic import ValidationError
from taskbroker_client.retry import Retry

from sentry.objectstore import get_preprod_session
from sentry.preprod.models import PreprodArtifact
from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval
from sentry.preprod.snapshots.image_diff.compare import compare_images_batch
from sentry.preprod.snapshots.image_diff.odiff import OdiffServer
from sentry.preprod.snapshots.manifest import (
Expand Down Expand Up @@ -113,6 +114,128 @@ def _create_pixel_batches(
return batches


class ImageFingerprint(NamedTuple):
name: str
status: str
head_hash: str | None = None
previous_image_file_name: str | None = None


def _build_comparison_fingerprints(manifest: ComparisonManifest) -> set[ImageFingerprint]:
fingerprints: set[ImageFingerprint] = set()
for name, image in manifest.images.items():
if image.status == "unchanged":
continue
if image.status in ("changed", "added"):
if not image.head_hash:
continue
fingerprints.add(ImageFingerprint(name, image.status, image.head_hash))
elif image.status == "renamed":
if not image.head_hash or not image.previous_image_file_name:
continue
fingerprints.add(
ImageFingerprint(name, "renamed", image.head_hash, image.previous_image_file_name)
)
else:
fingerprints.add(ImageFingerprint(name, image.status))
return fingerprints


def _try_auto_approve_snapshot(
head_artifact: PreprodArtifact,
comparison_manifest: ComparisonManifest,
session: Session,
) -> None:
cc = head_artifact.commit_comparison
if not cc or not cc.pr_number or not cc.head_repo_name:
return

head_fingerprints = _build_comparison_fingerprints(comparison_manifest)
if not head_fingerprints:
return
Comment thread
NicoHinderling marked this conversation as resolved.

approved_sibling = (
PreprodArtifact.objects.filter(
project_id=head_artifact.project_id,
app_id=head_artifact.app_id,
build_configuration=head_artifact.build_configuration,
commit_comparison__pr_number=cc.pr_number,
commit_comparison__head_repo_name=cc.head_repo_name,
preprodcomparisonapproval__preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS,
preprodcomparisonapproval__approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED,
preprodsnapshotmetrics__snapshot_comparisons_head_metrics__state=PreprodSnapshotComparison.State.SUCCESS,
)
.exclude(id=head_artifact.id)
.order_by("-date_added")
.first()
)

if not approved_sibling:
return

sibling_comparison = (
PreprodSnapshotComparison.objects.filter(
head_snapshot_metrics__preprod_artifact=approved_sibling,
state=PreprodSnapshotComparison.State.SUCCESS,
)
.order_by("-date_updated")
.first()
)

if not sibling_comparison:
return

sibling_comparison_key = (sibling_comparison.extras or {}).get("comparison_key")
if not sibling_comparison_key:
return

try:
sibling_manifest = ComparisonManifest(
**orjson.loads(session.get(sibling_comparison_key).payload.read())
)
except Exception:
logger.exception(
"auto_approve: failed to load sibling comparison manifest",
extra={
"head_artifact_id": head_artifact.id,
"sibling_artifact_id": approved_sibling.id,
"comparison_key": sibling_comparison_key,
},
)
return

sibling_fingerprints = _build_comparison_fingerprints(sibling_manifest)

if head_fingerprints != sibling_fingerprints:
logger.info(
"auto_approve: fingerprints do not match",
extra={
"head_artifact_id": head_artifact.id,
"sibling_artifact_id": approved_sibling.id,
},
)
return

PreprodComparisonApproval.objects.create(
preprod_artifact=head_artifact,
preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS,
approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED,
approved_at=timezone.now(),
extras={
"auto_approval": True,
"prev_approved_artifact_id": approved_sibling.id,
},
)
Comment thread
sentry[bot] marked this conversation as resolved.

logger.info(
"auto_approve: snapshot auto-approved",
extra={
"head_artifact_id": head_artifact.id,
"prev_approved_artifact_id": approved_sibling.id,
},
)


@instrumented_task(
name="sentry.preprod.tasks.compare_snapshots",
namespace=preprod_tasks,
Expand Down Expand Up @@ -581,6 +704,14 @@ def _fetch_hash(h: str) -> None:
):
metrics.incr("preprod.snapshots.diff.zero_changes", sample_rate=1.0, tags=metric_tags)

try:
_try_auto_approve_snapshot(head_artifact, comparison_manifest, session)
except Exception:
logger.exception(
"Auto-approve failed after successful comparison",
extra={"head_artifact_id": head_artifact_id},
)

create_preprod_snapshot_status_check_task.apply_async(
kwargs={
"preprod_artifact_id": head_artifact_id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {Tag} from '@sentry/scraps/badge';
import {Button} from '@sentry/scraps/button';
import {Flex} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';

import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {Client} from 'sentry/api';
Expand All @@ -15,6 +16,7 @@ import {
IconCheckmark,
IconDelete,
IconEllipsis,
IconInfo,
IconRefresh,
IconThumb,
IconTimer,
Expand Down Expand Up @@ -47,6 +49,7 @@ export function SnapshotHeaderActions({
const [isDeleting, setIsDeleting] = useState(false);

const isApproved = data.approval_info?.status === 'approved';
const isAutoApproved = data.approval_info?.is_auto_approved ?? false;
const approvers: AvatarUser[] = (data.approval_info?.approvers ?? []).map((a, i) => ({
id: a.id ?? `approver-${i}`,
name: a.name ?? '',
Expand Down Expand Up @@ -148,9 +151,22 @@ export function SnapshotHeaderActions({
{data.approval_info &&
(isApproved ? (
<Flex align="center" gap="xl">
<Tag variant="success" icon={<IconCheckmark />}>
{t('Approved')}
</Tag>
<Flex align="center" gap="xs">
<Tag variant="success" icon={<IconCheckmark />}>
{isAutoApproved ? t('Auto-approved') : t('Approved')}
</Tag>
{isAutoApproved && (
<Tooltip
title={t(
'Automatically approved because the changes match a previously approved build on this PR.'
)}
>
<Flex align="center">
<IconInfo size="sm" />
</Flex>
</Tooltip>
)}
</Flex>
{approvers.length > 0 && (
<AvatarList users={approvers} avatarSize={24} maxVisibleAvatars={2} />
)}
Expand Down
1 change: 1 addition & 0 deletions static/app/views/preprod/types/snapshotTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface SnapshotApprover {
export interface SnapshotApprovalInfo {
approvers: SnapshotApprover[];
status: 'approved' | 'requires_approval';
is_auto_approved?: boolean;
}

export interface SnapshotDetailsApiResponse {
Expand Down
Loading
Loading