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 && ( + )} @@ -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, } ]