diff --git a/src/sentry/preprod/api/endpoints/builds.py b/src/sentry/preprod/api/endpoints/builds.py
index 59f28d810eac7a..8bb20e598ed62f 100644
--- a/src/sentry/preprod/api/endpoints/builds.py
+++ b/src/sentry/preprod/api/endpoints/builds.py
@@ -77,8 +77,16 @@ def on_results(artifacts: list[PreprodArtifact]) -> list[dict[str, Any]]:
try:
queryset = queryset_for_query(query, organization)
queryset = queryset.select_related(
- "project", "build_configuration", "commit_comparison", "mobile_app_info"
- ).prefetch_related("preprodartifactsizemetrics_set")
+ "project",
+ "build_configuration",
+ "commit_comparison",
+ "mobile_app_info",
+ "preprodsnapshotmetrics",
+ ).prefetch_related(
+ "preprodartifactsizemetrics_set",
+ "preprodsnapshotmetrics__snapshot_comparisons_head_metrics",
+ "preprodcomparisonapproval_set",
+ )
queryset = queryset.filter(date_added__gte=cutoff)
if start:
queryset = queryset.filter(date_added__gte=start)
diff --git a/src/sentry/preprod/api/models/project_preprod_build_details_models.py b/src/sentry/preprod/api/models/project_preprod_build_details_models.py
index 1ab3362ca18562..0f38a17fab299e 100644
--- a/src/sentry/preprod/api/models/project_preprod_build_details_models.py
+++ b/src/sentry/preprod/api/models/project_preprod_build_details_models.py
@@ -9,7 +9,13 @@
get_download_count_for_artifact,
is_installable_artifact,
)
-from sentry.preprod.models import Platform, PreprodArtifact, PreprodArtifactSizeMetrics
+from sentry.preprod.models import (
+ Platform,
+ PreprodArtifact,
+ PreprodArtifactSizeMetrics,
+ PreprodComparisonApproval,
+)
+from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics
from sentry.preprod.vcs.status_checks.size.tasks import StatusCheckErrorType
logger = logging.getLogger(__name__)
@@ -83,6 +89,17 @@ class PostedStatusChecks(BaseModel):
size: StatusCheckResult | None = None
+class SnapshotComparisonInfo(BaseModel):
+ image_count: int
+ comparison_state: Literal["pending", "processing", "success", "failed"] | None = None
+ comparison_error_message: str | None = None
+ images_added: int = 0
+ images_removed: int = 0
+ images_changed: int = 0
+ images_unchanged: int = 0
+ approval_status: Literal["approved", "requires_approval"] | None = None
+
+
class SizeInfoSizeMetric(BaseModel):
metrics_artifact_type: PreprodArtifactSizeMetrics.MetricsArtifactType
install_size_bytes: int
@@ -147,6 +164,7 @@ class BuildDetailsApiResponse(BaseModel):
posted_status_checks: PostedStatusChecks | None = None
base_artifact_id: str | None = None
base_build_info: BuildDetailsAppInfo | None = None
+ snapshot_comparison_info: SnapshotComparisonInfo | None = None
def create_build_details_app_info(artifact: PreprodArtifact) -> BuildDetailsAppInfo:
@@ -262,6 +280,61 @@ def to_size_info(
raise ValueError(f"Unknown SizeAnalysisState {main_metric.state}")
+def to_snapshot_comparison_info(head_artifact: PreprodArtifact) -> SnapshotComparisonInfo | None:
+ try:
+ snapshot_metrics = head_artifact.preprodsnapshotmetrics
+ except PreprodSnapshotMetrics.DoesNotExist:
+ return None
+
+ comparison_state = None
+ comparison_error_message = None
+ images_added = 0
+ images_removed = 0
+ images_changed = 0
+ images_unchanged = 0
+
+ comparisons = sorted(
+ snapshot_metrics.snapshot_comparisons_head_metrics.all(),
+ key=lambda c: c.id,
+ reverse=True,
+ )
+ comparison = comparisons[0] if comparisons else None
+ if comparison:
+ comparison_state = PreprodSnapshotComparison.State(comparison.state).name.lower()
+ if comparison.state == PreprodSnapshotComparison.State.SUCCESS:
+ images_added = comparison.images_added
+ images_removed = comparison.images_removed
+ images_changed = comparison.images_changed
+ images_unchanged = comparison.images_unchanged
+ elif comparison.state == PreprodSnapshotComparison.State.FAILED:
+ comparison_error_message = comparison.error_message
+
+ approval_status = None
+ # REJECTED is no longer used; all non-APPROVED statuses are treated as requires_approval
+ approvals = [
+ a
+ for a in head_artifact.preprodcomparisonapproval_set.all()
+ if a.preprod_feature_type == PreprodComparisonApproval.FeatureType.SNAPSHOTS
+ ]
+ approvals.sort(key=lambda a: a.id, reverse=True)
+ if approvals:
+ if approvals[0].approval_status == PreprodComparisonApproval.ApprovalStatus.APPROVED:
+ approval_status = "approved"
+ else:
+ approval_status = "requires_approval"
+
+ return SnapshotComparisonInfo(
+ image_count=snapshot_metrics.image_count,
+ comparison_state=comparison_state,
+ comparison_error_message=comparison_error_message,
+ images_added=images_added,
+ images_removed=images_removed,
+ images_changed=images_changed,
+ images_unchanged=images_unchanged,
+ approval_status=approval_status,
+ )
+
+
def transform_preprod_artifact_to_build_details(
artifact: PreprodArtifact,
) -> BuildDetailsApiResponse:
@@ -315,6 +388,8 @@ def transform_preprod_artifact_to_build_details(
posted_status_checks = _parse_posted_status_checks(artifact)
+ snapshot_comparison_info = to_snapshot_comparison_info(artifact)
+
return BuildDetailsApiResponse(
id=artifact.id,
state=artifact.state,
@@ -327,6 +402,7 @@ def transform_preprod_artifact_to_build_details(
posted_status_checks=posted_status_checks,
base_artifact_id=base_artifact.id if base_artifact else None,
base_build_info=base_build_info,
+ snapshot_comparison_info=snapshot_comparison_info,
)
diff --git a/static/app/components/preprod/preprodBuildsDisplay.ts b/static/app/components/preprod/preprodBuildsDisplay.ts
index 899721caa0eca4..87908a71373737 100644
--- a/static/app/components/preprod/preprodBuildsDisplay.ts
+++ b/static/app/components/preprod/preprodBuildsDisplay.ts
@@ -1,6 +1,7 @@
export enum PreprodBuildsDisplay {
SIZE = 'size',
DISTRIBUTION = 'distribution',
+ SNAPSHOT = 'snapshot',
}
export function getPreprodBuildsDisplay(
@@ -13,6 +14,8 @@ export function getPreprodBuildsDisplay(
switch (display) {
case PreprodBuildsDisplay.DISTRIBUTION:
return PreprodBuildsDisplay.DISTRIBUTION;
+ case PreprodBuildsDisplay.SNAPSHOT:
+ return PreprodBuildsDisplay.SNAPSHOT;
case PreprodBuildsDisplay.SIZE:
default:
return PreprodBuildsDisplay.SIZE;
diff --git a/static/app/components/preprod/preprodBuildsSearchControls.tsx b/static/app/components/preprod/preprodBuildsSearchControls.tsx
index b51ff573495ee2..ecb021d29f77d2 100644
--- a/static/app/components/preprod/preprodBuildsSearchControls.tsx
+++ b/static/app/components/preprod/preprodBuildsSearchControls.tsx
@@ -35,6 +35,10 @@ interface PreprodBuildsSearchControlsProps {
* are shown.
*/
allowedKeys?: string[];
+ /**
+ * Hide the display mode toggle
+ */
+ hideDisplayToggle?: boolean;
/**
* Called on every keystroke (for controlled input with debounce)
*/
@@ -54,6 +58,7 @@ export function PreprodBuildsSearchControls({
display,
projects,
allowedKeys = MOBILE_BUILDS_ALLOWED_KEYS,
+ hideDisplayToggle,
onChange,
onSearch,
onDisplayChange,
@@ -74,20 +79,22 @@ export function PreprodBuildsSearchControls({
projects={projects}
/>
-
- onDisplayChange(option.value)}
- trigger={triggerProps => (
-
- )}
- />
-
+ {!hideDisplayToggle && (
+
+ onDisplayChange(option.value)}
+ trigger={triggerProps => (
+
+ )}
+ />
+
+ )}
);
}
diff --git a/static/app/components/preprod/preprodBuildsSnapshotTable.tsx b/static/app/components/preprod/preprodBuildsSnapshotTable.tsx
new file mode 100644
index 00000000000000..d7ccd1a7d251af
--- /dev/null
+++ b/static/app/components/preprod/preprodBuildsSnapshotTable.tsx
@@ -0,0 +1,231 @@
+import type {ReactNode} from 'react';
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {Tag} from '@sentry/scraps/badge';
+import InteractionStateLayer from '@sentry/scraps/interactionStateLayer';
+import {Flex} from '@sentry/scraps/layout';
+import {Text} from '@sentry/scraps/text';
+import {Tooltip} from '@sentry/scraps/tooltip';
+
+import {SimpleTable} from 'sentry/components/tables/simpleTable';
+import {TimeSince} from 'sentry/components/timeSince';
+import {IconCommit} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import type {
+ BuildDetailsApiResponse,
+ SnapshotApprovalStatus,
+ SnapshotComparisonState,
+} from 'sentry/views/preprod/types/buildDetailsTypes';
+import {getSnapshotPath} from 'sentry/views/preprod/utils/buildLinkUtils';
+
+import {FullRowLink} from './preprodBuildsTableCommon';
+
+interface PreprodBuildsSnapshotTableProps {
+ builds: BuildDetailsApiResponse[];
+ organizationSlug: string;
+ showProjectColumn: boolean;
+ content?: ReactNode;
+ onRowClick?: (build: BuildDetailsApiResponse) => void;
+}
+
+function ApprovalBadge({
+ comparisonState,
+ approvalStatus,
+}: {
+ approvalStatus: SnapshotApprovalStatus | null | undefined;
+ comparisonState: SnapshotComparisonState | null | undefined;
+}) {
+ if (!comparisonState || comparisonState !== 'success') {
+ return {'–'};
+ }
+ if (approvalStatus === 'approved') {
+ return {t('Approved')};
+ }
+ if (approvalStatus === 'requires_approval') {
+ return {t('Needs Approval')};
+ }
+ return {'–'};
+}
+
+function ChangeCounts({
+ added,
+ removed,
+ changed,
+ unchanged,
+ comparisonState,
+ errorMessage,
+}: {
+ added: number;
+ changed: number;
+ comparisonState: SnapshotComparisonState | null | undefined;
+ errorMessage: string | null | undefined;
+ removed: number;
+ unchanged: number;
+}) {
+ if (!comparisonState) {
+ return {t('Base')};
+ }
+ if (comparisonState === 'pending') {
+ return (
+
+ {t('Pending')}
+
+ );
+ }
+ if (comparisonState === 'processing') {
+ return (
+
+ {t('Processing')}
+
+ );
+ }
+ if (comparisonState === 'failed') {
+ return (
+
+ {t('Failed')}
+
+ );
+ }
+ if (added === 0 && removed === 0 && changed === 0) {
+ return (
+
+ {t('No changes')}
+
+ );
+ }
+ const parts: string[] = [];
+ if (added > 0) {
+ parts.push(t('%s added', added));
+ }
+ if (removed > 0) {
+ parts.push(t('%s removed', removed));
+ }
+ if (changed > 0) {
+ parts.push(t('%s modified', changed));
+ }
+ if (unchanged > 0) {
+ parts.push(t('%s unchanged', unchanged));
+ }
+ return (
+
+ {parts.join(', ')}
+
+ );
+}
+
+export function PreprodBuildsSnapshotTable({
+ builds,
+ content,
+ onRowClick,
+ organizationSlug,
+ showProjectColumn,
+}: PreprodBuildsSnapshotTableProps) {
+ const rows = builds.map(build => {
+ const linkUrl = getSnapshotPath({
+ organizationSlug,
+ snapshotId: build.id,
+ });
+ const info = build.snapshot_comparison_info;
+ const appId = build.app_info?.app_id;
+ return (
+
+ onRowClick?.(build)}>
+
+
+
+
+ {appId || t('Snapshot')}
+
+ {t('%s images', info?.image_count ?? 0)}
+
+
+
+ {showProjectColumn && (
+
+ {build.project_slug}
+
+ )}
+
+
+
+
+
+ {build.vcs_info?.head_ref && (
+
+
+ {build.vcs_info.head_ref}
+
+ {build.vcs_info?.pr_number && (
+
+ #{build.vcs_info.pr_number}
+
+ )}
+
+ )}
+
+
+
+ {(build.vcs_info?.head_sha?.slice(0, 7) || '–').toUpperCase()}
+
+
+
+
+
+
+
+
+ {build.app_info?.date_added ? (
+
+ ) : (
+ '–'
+ )}
+
+
+
+
+ );
+ });
+
+ return (
+
+
+ {t('Snapshot')}
+ {showProjectColumn && (
+ {t('Project')}
+ )}
+ {t('Changes')}
+ {t('Branch')}
+ {t('Approval')}
+ {t('Created')}
+
+ {content ?? rows}
+
+ );
+}
+
+const snapshotTableColumns = {
+ withProject: `minmax(200px, 2fr) minmax(100px, 1fr) minmax(100px, 140px)
+ minmax(180px, 2fr) minmax(100px, 1fr) minmax(80px, 120px)`,
+ withoutProject: `minmax(200px, 2fr) minmax(100px, 140px)
+ minmax(180px, 2fr) minmax(100px, 1fr) minmax(80px, 120px)`,
+};
+
+const BuildsSnapshotTable = styled(SimpleTable)<{showProjectColumn?: boolean}>`
+ overflow-x: auto;
+ overflow-y: auto;
+ grid-template-columns: ${p =>
+ p.showProjectColumn
+ ? snapshotTableColumns.withProject
+ : snapshotTableColumns.withoutProject};
+`;
diff --git a/static/app/components/preprod/preprodBuildsTable.tsx b/static/app/components/preprod/preprodBuildsTable.tsx
index 27f980a2039bf2..c1b4ca707092da 100644
--- a/static/app/components/preprod/preprodBuildsTable.tsx
+++ b/static/app/components/preprod/preprodBuildsTable.tsx
@@ -14,6 +14,7 @@ import {getLabels} from 'sentry/views/preprod/utils/labelUtils';
import {PreprodBuildsDisplay} from './preprodBuildsDisplay';
import {PreprodBuildsDistributionTable} from './preprodBuildsDistributionTable';
import {PreprodBuildsSizeTable} from './preprodBuildsSizeTable';
+import {PreprodBuildsSnapshotTable} from './preprodBuildsSnapshotTable';
interface PreprodBuildsTableProps {
builds: BuildDetailsApiResponse[];
@@ -51,10 +52,12 @@ export function PreprodBuildsTable({
hasSearchQuery,
showProjectColumn = false,
}: PreprodBuildsTableProps) {
- const isDistributionDisplay = display === PreprodBuildsDisplay.DISTRIBUTION;
- const emptyStateDocUrl = isDistributionDisplay
- ? 'https://docs.sentry.io/product/build-distribution/'
- : 'https://docs.sentry.io/product/size-analysis/';
+ const emptyStateDocUrl =
+ display === PreprodBuildsDisplay.DISTRIBUTION
+ ? 'https://docs.sentry.io/product/build-distribution/'
+ : display === PreprodBuildsDisplay.SNAPSHOT
+ ? 'https://docs.sentry.io/product/snapshot-testing/'
+ : 'https://docs.sentry.io/product/size-analysis/';
const hasMultiplePlatforms = useMemo(() => {
const platforms = new Set(builds.map(b => b.app_info?.platform).filter(Boolean));
@@ -79,12 +82,25 @@ export function PreprodBuildsTable({
{hasSearchQuery
- ? t('No mobile builds found for your search')
- : tct('No mobile builds found, see our [link:documentation] for more info.', {
- link: (
- {t('Learn more')}
- ),
- })}
+ ? display === PreprodBuildsDisplay.SNAPSHOT
+ ? t('No snapshots found for your search')
+ : t('No mobile builds found for your search')
+ : display === PreprodBuildsDisplay.SNAPSHOT
+ ? tct('No snapshots found, see our [link:documentation] for more info.', {
+ link: (
+ {t('Learn more')}
+ ),
+ })
+ : tct(
+ 'No mobile builds found, see our [link:documentation] for more info.',
+ {
+ link: (
+
+ {t('Learn more')}
+
+ ),
+ }
+ )}
);
@@ -92,7 +108,15 @@ export function PreprodBuildsTable({
return (
- {isDistributionDisplay ? (
+ {display === PreprodBuildsDisplay.SNAPSHOT ? (
+
+ ) : display === PreprodBuildsDisplay.DISTRIBUTION ? (
{
- if (!shouldShowMobileBuildsTab) {
+ if (!shouldShowPreprodTabs) {
return 'releases';
}
- return (decodeScalar(location.query.tab) as ReleaseTab | undefined) || 'releases';
- }, [shouldShowMobileBuildsTab, location.query.tab]);
+ const tab = decodeScalar(location.query.tab) as ReleaseTab | undefined;
+ if (tab === 'snapshots' && !shouldShowSnapshotsTab) {
+ return 'releases';
+ }
+ if (tab === 'mobile-builds' && !shouldShowMobileBuildsTab) {
+ return 'releases';
+ }
+ return tab || 'releases';
+ }, [
+ shouldShowPreprodTabs,
+ shouldShowMobileBuildsTab,
+ shouldShowSnapshotsTab,
+ location.query.tab,
+ ]);
const handleSearch = useCallback(
(query: string) => {
@@ -470,10 +486,14 @@ export default function ReleasesList() {
-
+
- {shouldShowMobileBuildsTab && (
+ {shouldShowPreprodTabs && (
+
+
+ {t('Snapshots')}
+
+
+
)}
@@ -527,6 +566,15 @@ export default function ReleasesList() {
/>
)}
+ {selectedTab === 'snapshots' && shouldShowSnapshotsTab && (
+
+ )}
+
{selectedTab === 'releases' && (
`
- ${p => !p.shouldShowMobileBuildsTab && `margin-bottom: ${p.theme.space.xl};`}
+const ReleasesPageFilterBar = styled(PageFilterBar)<{shouldShowPreprodTabs: boolean}>`
+ ${p => !p.shouldShowPreprodTabs && `margin-bottom: ${p.theme.space.xl};`}
`;
const SortAndFilterWrapper = styled('div')`
diff --git a/static/app/views/releases/list/mobileBuilds.tsx b/static/app/views/releases/list/mobileBuilds.tsx
index ca784073d2b4d3..5f139ecdec6d42 100644
--- a/static/app/views/releases/list/mobileBuilds.tsx
+++ b/static/app/views/releases/list/mobileBuilds.tsx
@@ -6,6 +6,7 @@ import {Stack} from '@sentry/scraps/layout';
import {LoadingError} from 'sentry/components/loadingError';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
+import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants';
import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse';
import {
getPreprodBuildsDisplay,
@@ -28,17 +29,24 @@ import {MobileBuildsChart} from './mobileBuildsChart';
type Props = {
organization: Organization;
selectedProjectIds: string[];
+ defaultDisplay?: PreprodBuildsDisplay;
+ hideDisplayToggle?: boolean;
};
-export function MobileBuilds({organization, selectedProjectIds}: Props) {
+export function MobileBuilds({
+ organization,
+ selectedProjectIds,
+ defaultDisplay,
+ hideDisplayToggle,
+}: Props) {
const location = useLocation();
const navigate = useNavigate();
const [searchQuery] = useQueryState('query', parseAsString);
const [cursor] = useQueryState('cursor', parseAsString);
const activeDisplay = useMemo(
- () => getPreprodBuildsDisplay(location.query.display),
- [location.query.display]
+ () => defaultDisplay ?? getPreprodBuildsDisplay(location.query.display),
+ [defaultDisplay, location.query.display]
);
const buildsQueryParams = useMemo(() => {
@@ -104,9 +112,13 @@ export function MobileBuilds({organization, selectedProjectIds}: Props) {
const builds = buildsResponse?.json ?? [];
const pageLinks = buildsResponse?.headers.Link ?? undefined;
const hasSearchQuery = !!searchQuery?.trim();
- const showProjectColumn = selectedProjectIds.length > 1;
+ const showProjectColumn =
+ selectedProjectIds.length > 1 ||
+ (selectedProjectIds.length === 1 &&
+ selectedProjectIds[0] === `${ALL_ACCESS_PROJECTS}`);
const projectId = selectedProjectIds[0];
const shouldShowOnboarding =
+ activeDisplay !== PreprodBuildsDisplay.SNAPSHOT &&
builds.length === 0 &&
!isLoadingBuilds &&
!buildsError &&
@@ -123,7 +135,10 @@ export function MobileBuilds({organization, selectedProjectIds}: Props) {
enabled: selectedProjectIds.length > 0,
error: !!buildsError,
isLoading: isLoadingBuilds,
- pageSource: 'releases_mobile_builds_tab',
+ pageSource:
+ activeDisplay === PreprodBuildsDisplay.SNAPSHOT
+ ? 'releases_snapshots_tab'
+ : 'releases_mobile_builds_tab',
projectCount: selectedProjectIds.length,
searchQuery,
});
@@ -159,6 +174,7 @@ export function MobileBuilds({organization, selectedProjectIds}: Props) {
initialQuery={searchQuery ?? ''}
display={activeDisplay}
projects={selectedProjectIds.map(Number)}
+ hideDisplayToggle={hideDisplayToggle}
onSearch={handleSearch}
onDisplayChange={handleDisplayChange}
/>
diff --git a/tests/sentry/preprod/api/endpoints/test_builds.py b/tests/sentry/preprod/api/endpoints/test_builds.py
index aabc3ec70af7b3..5c2e466cc9df07 100644
--- a/tests/sentry/preprod/api/endpoints/test_builds.py
+++ b/tests/sentry/preprod/api/endpoints/test_builds.py
@@ -113,6 +113,7 @@ def test_one_build(self) -> None:
"posted_status_checks": None,
"project_slug": "bar",
"size_info": None,
+ "snapshot_comparison_info": None,
}
]