diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py index b52166275c2806..c5703b8cc59641 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py @@ -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) 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 diff --git a/src/sentry/preprod/api/models/snapshots/project_preprod_snapshot_models.py b/src/sentry/preprod/api/models/snapshots/project_preprod_snapshot_models.py index a9c3c8efc98cea..054b91e893471a 100644 --- a/src/sentry/preprod/api/models/snapshots/project_preprod_snapshot_models.py +++ b/src/sentry/preprod/api/models/snapshots/project_preprod_snapshot_models.py @@ -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): diff --git a/src/sentry/preprod/snapshots/tasks.py b/src/sentry/preprod/snapshots/tasks.py index 5ce11f0f8b4ab6..d93b513e53817a 100644 --- a/src/sentry/preprod/snapshots/tasks.py +++ b/src/sentry/preprod/snapshots/tasks.py @@ -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 ( @@ -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 + + 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, + }, + ) + + 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, @@ -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, diff --git a/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx b/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx index ded2ee207b8817..44761cf64f2e87 100644 --- a/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx +++ b/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx @@ -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'; @@ -15,6 +16,7 @@ import { IconCheckmark, IconDelete, IconEllipsis, + IconInfo, IconRefresh, IconThumb, IconTimer, @@ -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 ?? '', @@ -148,9 +151,22 @@ export function SnapshotHeaderActions({ {data.approval_info && (isApproved ? ( - }> - {t('Approved')} - + + }> + {isAutoApproved ? t('Auto-approved') : t('Approved')} + + {isAutoApproved && ( + + + + + + )} + {approvers.length > 0 && ( )} diff --git a/static/app/views/preprod/types/snapshotTypes.ts b/static/app/views/preprod/types/snapshotTypes.ts index fa9dbfb9480e3c..255b4e2fd3f100 100644 --- a/static/app/views/preprod/types/snapshotTypes.ts +++ b/static/app/views/preprod/types/snapshotTypes.ts @@ -36,6 +36,7 @@ export interface SnapshotApprover { export interface SnapshotApprovalInfo { approvers: SnapshotApprover[]; status: 'approved' | 'requires_approval'; + is_auto_approved?: boolean; } export interface SnapshotDetailsApiResponse { diff --git a/tests/sentry/preprod/snapshots/test_auto_approve.py b/tests/sentry/preprod/snapshots/test_auto_approve.py new file mode 100644 index 00000000000000..310c3617dc9d3a --- /dev/null +++ b/tests/sentry/preprod/snapshots/test_auto_approve.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import orjson + +from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval +from sentry.preprod.snapshots.manifest import ( + ComparisonImageResult, + ComparisonManifest, + ComparisonSummary, +) +from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics +from sentry.preprod.snapshots.tasks import ( + ImageFingerprint, + _build_comparison_fingerprints, + _try_auto_approve_snapshot, +) +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import cell_silo_test + + +class BuildComparisonFingerprintsTest(TestCase): + def _make_manifest(self, images: dict[str, ComparisonImageResult]) -> ComparisonManifest: + changed = sum(1 for i in images.values() if i.status == "changed") + added = sum(1 for i in images.values() if i.status == "added") + removed = sum(1 for i in images.values() if i.status == "removed") + errored = sum(1 for i in images.values() if i.status == "errored") + renamed = sum(1 for i in images.values() if i.status == "renamed") + unchanged = sum(1 for i in images.values() if i.status == "unchanged") + return ComparisonManifest( + head_artifact_id=1, + base_artifact_id=2, + summary=ComparisonSummary( + total=len(images), + changed=changed, + added=added, + removed=removed, + errored=errored, + renamed=renamed, + unchanged=unchanged, + ), + images=images, + ) + + def test_mixed_statuses(self): + manifest = self._make_manifest( + { + "unchanged.png": ComparisonImageResult( + status="unchanged", head_hash="a", base_hash="a" + ), + "changed.png": ComparisonImageResult( + status="changed", head_hash="b", base_hash="c" + ), + "added.png": ComparisonImageResult(status="added", head_hash="d"), + "removed.png": ComparisonImageResult(status="removed", base_hash="e"), + "errored.png": ComparisonImageResult(status="errored"), + "renamed.png": ComparisonImageResult( + status="renamed", head_hash="f", previous_image_file_name="old.png" + ), + } + ) + fps = _build_comparison_fingerprints(manifest) + assert fps == { + ImageFingerprint("changed.png", "changed", "b"), + ImageFingerprint("added.png", "added", "d"), + ImageFingerprint("removed.png", "removed"), + ImageFingerprint("errored.png", "errored"), + ImageFingerprint("renamed.png", "renamed", "f", "old.png"), + } + + def test_empty_manifest_returns_empty_set(self): + manifest = self._make_manifest({}) + fps = _build_comparison_fingerprints(manifest) + assert fps == set() + + def test_skips_changed_with_missing_head_hash(self): + manifest = self._make_manifest( + { + "no_hash.png": ComparisonImageResult(status="changed", base_hash="abc"), + "has_hash.png": ComparisonImageResult( + status="changed", head_hash="def", base_hash="ghi" + ), + } + ) + fps = _build_comparison_fingerprints(manifest) + assert fps == {ImageFingerprint("has_hash.png", "changed", "def")} + + def test_skips_renamed_with_missing_hash_or_previous_name(self): + manifest = self._make_manifest( + { + "no_hash.png": ComparisonImageResult( + status="renamed", previous_image_file_name="old.png" + ), + "no_prev.png": ComparisonImageResult(status="renamed", head_hash="abc"), + "valid.png": ComparisonImageResult( + status="renamed", head_hash="def", previous_image_file_name="old_valid.png" + ), + } + ) + fps = _build_comparison_fingerprints(manifest) + assert fps == {ImageFingerprint("valid.png", "renamed", "def", "old_valid.png")} + + +def _mock_session_with_manifests(manifests_by_key: dict[str, bytes]) -> MagicMock: + session = MagicMock() + + def _get(key): + result = MagicMock() + if key in manifests_by_key: + result.payload.read.return_value = manifests_by_key[key] + else: + raise Exception(f"Key not found: {key}") + return result + + session.get.side_effect = _get + return session + + +@cell_silo_test +class TryAutoApproveSnapshotTest(TestCase): + def setUp(self): + super().setUp() + self.organization = self.create_organization(owner=self.user) + self.project = self.create_project(organization=self.organization) + + def _create_approved_sibling( + self, + pr_number: int, + comparison_images: dict, + app_id: str = "com.example.app", + build_configuration=None, + ) -> tuple[PreprodArtifact, str, bytes]: + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=pr_number, + head_repo_name="owner/repo", + ) + artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id=app_id, + build_configuration=build_configuration, + ) + head_metrics = PreprodSnapshotMetrics.objects.create( + preprod_artifact=artifact, + image_count=10, + ) + base_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=self.create_commit_comparison(organization=self.organization), + ) + base_metrics = PreprodSnapshotMetrics.objects.create( + preprod_artifact=base_artifact, + image_count=10, + ) + comparison_key = f"{self.organization.id}/{self.project.id}/{artifact.id}/{base_artifact.id}/comparison.json" + PreprodSnapshotComparison.objects.create( + head_snapshot_metrics=head_metrics, + base_snapshot_metrics=base_metrics, + state=PreprodSnapshotComparison.State.SUCCESS, + images_changed=1, + extras={"comparison_key": comparison_key}, + ) + PreprodComparisonApproval.objects.create( + preprod_artifact=artifact, + preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ) + + manifest = ComparisonManifest( + head_artifact_id=artifact.id, + base_artifact_id=base_artifact.id, + summary=ComparisonSummary( + total=len(comparison_images), + changed=sum(1 for i in comparison_images.values() if i.status == "changed"), + added=sum(1 for i in comparison_images.values() if i.status == "added"), + removed=sum(1 for i in comparison_images.values() if i.status == "removed"), + errored=sum(1 for i in comparison_images.values() if i.status == "errored"), + renamed=sum(1 for i in comparison_images.values() if i.status == "renamed"), + unchanged=sum(1 for i in comparison_images.values() if i.status == "unchanged"), + ), + images=comparison_images, + ) + return artifact, comparison_key, orjson.dumps(manifest.dict()) + + def _create_head_manifest(self, images: dict) -> ComparisonManifest: + return ComparisonManifest( + head_artifact_id=999, + base_artifact_id=998, + summary=ComparisonSummary( + total=len(images), + changed=sum(1 for i in images.values() if i.status == "changed"), + added=sum(1 for i in images.values() if i.status == "added"), + removed=sum(1 for i in images.values() if i.status == "removed"), + errored=sum(1 for i in images.values() if i.status == "errored"), + renamed=sum(1 for i in images.values() if i.status == "renamed"), + unchanged=sum(1 for i in images.values() if i.status == "unchanged"), + ), + images=images, + ) + + def test_auto_approves_when_fingerprints_match(self): + shared_images = { + "screen1.png": ComparisonImageResult( + status="changed", head_hash="abc", base_hash="old1" + ), + "screen2.png": ComparisonImageResult( + status="unchanged", head_hash="same", base_hash="same" + ), + } + sibling, comp_key, comp_json = self._create_approved_sibling( + pr_number=42, + comparison_images=shared_images, + ) + + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id="com.example.app", + ) + + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult( + status="changed", head_hash="abc", base_hash="new_base1" + ), + "screen2.png": ComparisonImageResult( + status="unchanged", head_hash="same", base_hash="same" + ), + "screen3.png": ComparisonImageResult( + status="unchanged", head_hash="extra", base_hash="extra" + ), + } + ) + + session = _mock_session_with_manifests({comp_key: comp_json}) + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + + approval = PreprodComparisonApproval.objects.get( + preprod_artifact=head_artifact, + preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ) + assert approval.approved_by_id is None + assert approval.approved_at is not None + assert approval.extras is not None + assert approval.extras["auto_approval"] is True + assert approval.extras["prev_approved_artifact_id"] == sibling.id + + def test_no_auto_approve_when_fingerprints_differ(self): + sibling_images = { + "screen1.png": ComparisonImageResult( + status="changed", head_hash="abc", base_hash="old1" + ), + } + _, comp_key, comp_json = self._create_approved_sibling( + pr_number=42, + comparison_images=sibling_images, + ) + + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id="com.example.app", + ) + + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult( + status="changed", head_hash="DIFFERENT", base_hash="old1" + ), + } + ) + + session = _mock_session_with_manifests({comp_key: comp_json}) + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + + assert not PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists() + + def test_no_auto_approve_when_no_pr_number(self): + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=None, + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + ) + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult(status="changed", head_hash="abc"), + } + ) + session = MagicMock() + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + assert not PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists() + + def test_no_auto_approve_when_no_approved_sibling(self): + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id="com.example.app", + ) + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult(status="changed", head_hash="abc"), + } + ) + session = MagicMock() + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + assert not PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists() + + def test_no_auto_approve_when_no_changes(self): + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + ) + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult( + status="unchanged", head_hash="a", base_hash="a" + ), + } + ) + session = MagicMock() + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + assert not PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists() + + def test_handles_missing_comparison_manifest(self): + sibling_images = { + "screen1.png": ComparisonImageResult( + status="changed", head_hash="abc", base_hash="old1" + ), + } + _, comp_key, _ = self._create_approved_sibling( + pr_number=42, + comparison_images=sibling_images, + ) + + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id="com.example.app", + ) + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult(status="changed", head_hash="abc"), + } + ) + + session = MagicMock() + session.get.side_effect = Exception("Not found") + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + assert not PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists() + + def test_matches_on_app_id_and_build_config(self): + shared_images = { + "screen1.png": ComparisonImageResult( + status="changed", head_hash="abc", base_hash="old1" + ), + } + self._create_approved_sibling( + pr_number=42, + comparison_images=shared_images, + app_id="com.other.app", + ) + + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id="com.example.app", + ) + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult(status="changed", head_hash="abc"), + } + ) + session = MagicMock() + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + assert not PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists() + + def test_no_auto_approve_when_sibling_not_approved_for_snapshots(self): + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + sibling = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id="com.example.app", + ) + head_metrics = PreprodSnapshotMetrics.objects.create( + preprod_artifact=sibling, + image_count=10, + ) + base_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=self.create_commit_comparison(organization=self.organization), + ) + base_metrics = PreprodSnapshotMetrics.objects.create( + preprod_artifact=base_artifact, + image_count=10, + ) + PreprodSnapshotComparison.objects.create( + head_snapshot_metrics=head_metrics, + base_snapshot_metrics=base_metrics, + state=PreprodSnapshotComparison.State.SUCCESS, + images_changed=1, + ) + PreprodComparisonApproval.objects.create( + preprod_artifact=sibling, + preprod_feature_type=PreprodComparisonApproval.FeatureType.SIZE, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ) + + cc2 = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc2, + app_id="com.example.app", + ) + head_manifest = self._create_head_manifest( + { + "screen1.png": ComparisonImageResult(status="changed", head_hash="abc"), + } + ) + session = MagicMock() + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + assert not PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists() + + def test_auto_approves_with_renamed_images(self): + shared_images = { + "new_name.png": ComparisonImageResult( + status="renamed", head_hash="abc", previous_image_file_name="old_name.png" + ), + } + _, comp_key, comp_json = self._create_approved_sibling( + pr_number=42, + comparison_images=shared_images, + ) + + cc = self.create_commit_comparison( + organization=self.organization, + pr_number=42, + head_repo_name="owner/repo", + ) + head_artifact = self.create_preprod_artifact( + project=self.project, + commit_comparison=cc, + app_id="com.example.app", + ) + + head_manifest = self._create_head_manifest( + { + "new_name.png": ComparisonImageResult( + status="renamed", head_hash="abc", previous_image_file_name="old_name.png" + ), + } + ) + + session = _mock_session_with_manifests({comp_key: comp_json}) + _try_auto_approve_snapshot(head_artifact, head_manifest, session) + assert PreprodComparisonApproval.objects.filter( + preprod_artifact=head_artifact, + approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, + ).exists()