From 717e0d0071905950b18716eb1a4e1ab2d2aed960 Mon Sep 17 00:00:00 2001
From: Max Topolsky <30879163+mtopo27@users.noreply.github.com>
Date: Tue, 21 Jul 2026 10:36:13 -0400
Subject: [PATCH 1/3] feat(preprod): Add settings UX for size analysis PR
comment rules
Adds a PR comments panel to the Size Analysis settings tab, mirroring the
existing status check rules panel. Users can toggle size PR comments on/off
(default off) and configure rules by metric, measurement, threshold,
artifact type, and search filter.
Refs EME-939
---
static/app/types/project.tsx | 2 +
.../analytics/preprodBuildAnalyticsEvents.tsx | 11 +
.../views/settings/project/preprod/index.tsx | 4 +
.../project/preprod/prCommentRules.spec.tsx | 190 +++++++++++++++
.../project/preprod/prCommentRules.tsx | 222 ++++++++++++++++++
.../project/preprod/usePrCommentRules.ts | 155 ++++++++++++
6 files changed, 584 insertions(+)
create mode 100644 static/app/views/settings/project/preprod/prCommentRules.spec.tsx
create mode 100644 static/app/views/settings/project/preprod/prCommentRules.tsx
create mode 100644 static/app/views/settings/project/preprod/usePrCommentRules.ts
diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx
index f95d735561e1..7f2f9b22767a 100644
--- a/static/app/types/project.tsx
+++ b/static/app/types/project.tsx
@@ -131,6 +131,8 @@ export interface DetailedProject extends ProjectSummary {
preprodDistributionPrCommentsEnabledByCustomer?: boolean;
preprodSizeEnabledByCustomer?: boolean;
preprodSizeEnabledQuery?: string | null;
+ preprodSizePrCommentsEnabled?: boolean;
+ preprodSizePrCommentsRules?: unknown[];
preprodSizeStatusChecksEnabled?: boolean;
preprodSizeStatusChecksRules?: unknown[];
preprodSnapshotPrCommentsEnabled?: boolean;
diff --git a/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx b/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx
index 58c3d41ef5ea..d74a2b2485da 100644
--- a/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx
+++ b/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx
@@ -78,6 +78,14 @@ export type PreprodBuildEventParameters = {
'preprod.releases.snapshots.tab-clicked': {
organization: Organization;
};
+ 'preprod.settings.pr_comment_rule_created': PreprodSettingsEvent;
+ 'preprod.settings.pr_comment_rule_deleted': PreprodSettingsEvent;
+ 'preprod.settings.pr_comment_rule_updated': PreprodSettingsEvent & {
+ artifact_type: ArtifactType;
+ measurement: string;
+ metric: string;
+ value: number;
+ };
'preprod.settings.status_check_rule_created': PreprodSettingsEvent;
'preprod.settings.status_check_rule_deleted': PreprodSettingsEvent;
'preprod.settings.status_check_rule_updated': PreprodSettingsEvent & {
@@ -169,4 +177,7 @@ export const preprodBuildEventMap: Record
+
+
+
)}
{tab === 'distribution' && (
diff --git a/static/app/views/settings/project/preprod/prCommentRules.spec.tsx b/static/app/views/settings/project/preprod/prCommentRules.spec.tsx
new file mode 100644
index 000000000000..88fa1a28aa9d
--- /dev/null
+++ b/static/app/views/settings/project/preprod/prCommentRules.spec.tsx
@@ -0,0 +1,190 @@
+import {DetailedProjectFixture} from 'sentry-fixture/project';
+import {RepositoryFixture} from 'sentry-fixture/repository';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {PrCommentRules} from 'sentry/views/settings/project/preprod/prCommentRules';
+
+describe('PrCommentRules', () => {
+ const {organization} = initializeOrg();
+ const initialRouterConfig = {
+ location: {
+ pathname: `/settings/projects/test-project/preprod/`,
+ },
+ route: '/settings/projects/:projectId/preprod/',
+ };
+
+ function mockRepositories(repositories = [RepositoryFixture()]) {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/repos/`,
+ body: repositories,
+ });
+ }
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('renders disabled by default when the project has no preprod options', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({options: {}});
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).not.toBeChecked();
+ expect(
+ screen.getByText('Enable PR comments above to configure rules.')
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', {name: 'Create PR Comment Rule'})
+ ).not.toBeInTheDocument();
+ });
+
+ it('reflects an enabled project from the explicit field', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizePrCommentsEnabled: true,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).toBeChecked();
+ expect(
+ screen.getByText('No PR comment rules configured. Create one to get started.')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {name: 'Create PR Comment Rule'})
+ ).toBeInTheDocument();
+ });
+
+ it('enables PR comments when toggled on', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({options: {}});
+ const projectEndpoint = `/projects/${organization.slug}/${project.slug}/`;
+ MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ body: project,
+ });
+ const mock = MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ method: 'PUT',
+ body: {},
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ await userEvent.click(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ );
+
+ await waitFor(() =>
+ expect(mock).toHaveBeenCalledWith(
+ projectEndpoint,
+ expect.objectContaining({
+ method: 'PUT',
+ data: {preprodSizePrCommentsEnabled: true},
+ })
+ )
+ );
+ });
+
+ it('creates a rule when the create button is clicked', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizePrCommentsEnabled: true,
+ });
+ const projectEndpoint = `/projects/${organization.slug}/${project.slug}/`;
+ MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ body: project,
+ });
+ const mock = MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ method: 'PUT',
+ body: {},
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ await userEvent.click(
+ await screen.findByRole('button', {name: 'Create PR Comment Rule'})
+ );
+
+ await waitFor(() =>
+ expect(mock).toHaveBeenCalledWith(
+ projectEndpoint,
+ expect.objectContaining({
+ method: 'PUT',
+ data: {
+ preprodSizePrCommentsRules: [
+ expect.objectContaining({
+ metric: 'install_size',
+ measurement: 'absolute',
+ value: 0,
+ artifactType: 'main_artifact',
+ }),
+ ],
+ },
+ })
+ )
+ );
+ });
+
+ it('shows the connect-a-repository empty state when there are no repositories', async () => {
+ mockRepositories([]);
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizePrCommentsEnabled: true,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByText(
+ 'Connect at least one repository to get Size Analysis PR comments'
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('checkbox', {name: 'Toggle PR comments'})
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/settings/project/preprod/prCommentRules.tsx b/static/app/views/settings/project/preprod/prCommentRules.tsx
new file mode 100644
index 000000000000..41886240a301
--- /dev/null
+++ b/static/app/views/settings/project/preprod/prCommentRules.tsx
@@ -0,0 +1,222 @@
+import {Fragment, useCallback, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import seerConfigBugSvg from 'sentry-images/spot/seer-config-bug-1.svg';
+
+import {Button, LinkButton} from '@sentry/scraps/button';
+import {Container, Flex, Stack} from '@sentry/scraps/layout';
+import {Switch} from '@sentry/scraps/switch';
+import {Heading, Text} from '@sentry/scraps/text';
+
+import {Panel} from 'sentry/components/panels/panel';
+import {PanelBody} from 'sentry/components/panels/panelBody';
+import {PanelHeader} from 'sentry/components/panels/panelHeader';
+import {IconAdd} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {useRepositories} from 'sentry/utils/useRepositories';
+import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
+
+import {StatusCheckRuleItem} from './statusCheckRuleItem';
+import {DEFAULT_ARTIFACT_TYPE} from './types';
+import {usePrCommentRules} from './usePrCommentRules';
+
+export function PrCommentRules() {
+ const organization = useOrganization();
+ const {project} = useProjectSettingsOutlet();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const {data: repositories, isPending: isLoadingRepos} = useRepositories({
+ orgSlug: organization.slug,
+ });
+ const {config, setEnabled, addRule, updateRule, deleteRule, createEmptyRule} =
+ usePrCommentRules(project);
+
+ const [newRuleId, setNewRuleId] = useState(null);
+
+ const expandedRuleIds = useMemo(() => {
+ const expanded = location.query.expanded;
+ if (!expanded) {
+ return new Set();
+ }
+ return new Set(Array.isArray(expanded) ? expanded : [expanded]);
+ }, [location.query.expanded]);
+
+ const handleAddRule = () => {
+ const newRule = createEmptyRule();
+ addRule(newRule);
+ trackAnalytics('preprod.settings.pr_comment_rule_created', {
+ organization,
+ project_slug: project.slug,
+ });
+ setNewRuleId(newRule.id);
+ updateExpandedInUrl([...expandedRuleIds, newRule.id]);
+ };
+
+ const updateExpandedInUrl = useCallback(
+ (expandedIds: string[]) => {
+ navigate(
+ {
+ query: {
+ ...location.query,
+ expanded: expandedIds,
+ },
+ },
+ {replace: true}
+ );
+ },
+ [location.query, navigate]
+ );
+
+ const handleToggleExpanded = (ruleId: string, isExpanded: boolean) => {
+ const newExpanded = new Set(expandedRuleIds);
+ if (isExpanded) {
+ newExpanded.add(ruleId);
+ } else {
+ newExpanded.delete(ruleId);
+ if (ruleId === newRuleId) {
+ setNewRuleId(null);
+ }
+ }
+ updateExpandedInUrl([...newExpanded]);
+ };
+
+ const hasRepositories = !isLoadingRepos && repositories && repositories.length > 0;
+
+ return (
+
+ {t('Size Analysis - PR Comments')}
+
+ {hasRepositories ? (
+
+
+
+
+ {t('PR Comments Enabled')}
+
+
+ {t("Sentry will post PR comments based on your build's app size.")}
+
+
+ setEnabled(!config.enabled)}
+ aria-label={t('Toggle PR comments')}
+ />
+
+
+ {config.enabled ? (
+
+ {config.rules.length > 0 ? (
+
+ {config.rules.map(rule => (
+
+ handleToggleExpanded(rule.id, isExpanded)
+ }
+ onSave={updated => {
+ updateRule(rule.id, updated);
+ trackAnalytics('preprod.settings.pr_comment_rule_updated', {
+ organization,
+ project_slug: project.slug,
+ metric: updated.metric,
+ measurement: updated.measurement,
+ artifact_type: updated.artifactType ?? DEFAULT_ARTIFACT_TYPE,
+ value: updated.value,
+ });
+ if (rule.id === newRuleId) {
+ setNewRuleId(null);
+ }
+ }}
+ onDelete={() => {
+ trackAnalytics('preprod.settings.pr_comment_rule_deleted', {
+ organization,
+ project_slug: project.slug,
+ });
+ deleteRule(rule.id);
+ if (rule.id === newRuleId) {
+ setNewRuleId(null);
+ }
+ const newExpanded = new Set(expandedRuleIds);
+ newExpanded.delete(rule.id);
+ updateExpandedInUrl([...newExpanded]);
+ }}
+ />
+ ))}
+
+ ) : (
+
+
+ {t('No PR comment rules configured. Create one to get started.')}
+
+
+ )}
+
+
+ } onClick={handleAddRule}>
+ {t('Create PR Comment Rule')}
+
+
+
+ ) : (
+
+
+ {t('Enable PR comments above to configure rules.')}
+
+
+ )}
+
+ ) : (
+
+
+ {t('Get the most out of Size Analysis')}
+
+ {t('Connect at least one repository to get Size Analysis PR comments')}
+
+
+ {t('Add Repo')}
+
+
+
+
+ )}
+
+
+ );
+}
+
+const AddRuleButton = styled(Button)`
+ align-self: flex-start;
+`;
+
+const EmptyStateContainer = styled('div')`
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ padding: 56px 48px;
+ gap: ${p => p.theme.space.xl};
+`;
+
+const ImageContainer = styled('div')`
+ width: 220px;
+ height: 220px;
+ background-image: url(${seerConfigBugSvg});
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ flex-shrink: 0;
+`;
diff --git a/static/app/views/settings/project/preprod/usePrCommentRules.ts b/static/app/views/settings/project/preprod/usePrCommentRules.ts
new file mode 100644
index 000000000000..be8ca0cde132
--- /dev/null
+++ b/static/app/views/settings/project/preprod/usePrCommentRules.ts
@@ -0,0 +1,155 @@
+import {useCallback, useMemo} from 'react';
+
+import {
+ addErrorMessage,
+ addLoadingMessage,
+ addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
+import {t} from 'sentry/locale';
+import type {DetailedProject} from 'sentry/types/project';
+import {uniqueId} from 'sentry/utils/guid';
+import {useUpdateProject} from 'sentry/utils/project/useUpdateProject';
+
+import {
+ DEFAULT_ARTIFACT_TYPE,
+ DEFAULT_MEASUREMENT_TYPE,
+ DEFAULT_METRIC_TYPE,
+ toArtifactType,
+ toMeasurementType,
+ toMetricType,
+ type StatusCheckRule,
+} from './types';
+
+const ENABLED_KEY = 'sentry:preprod_size_pr_comments_enabled';
+const RULES_KEY = 'sentry:preprod_size_pr_comments_rules';
+
+const DEFAULT_METRIC = DEFAULT_METRIC_TYPE;
+const DEFAULT_MEASUREMENT = DEFAULT_MEASUREMENT_TYPE;
+
+function parseRules(raw: unknown): StatusCheckRule[] {
+ if (!Array.isArray(raw)) {
+ return [];
+ }
+ return raw
+ .filter((r): r is Record => !!r && typeof r.id === 'string')
+ .map(r => {
+ const metric = toMetricType(r.metric, DEFAULT_METRIC);
+ const measurement = toMeasurementType(r.measurement, DEFAULT_MEASUREMENT);
+ const artifactType = toArtifactType(r.artifactType);
+ return {
+ id: r.id as string,
+ metric,
+ measurement,
+ value: typeof r.value === 'number' ? r.value : 0,
+ filterQuery: typeof r.filterQuery === 'string' ? r.filterQuery : '',
+ artifactType,
+ };
+ });
+}
+
+export function usePrCommentRules(project: DetailedProject) {
+ const updateProject = useUpdateProject(project);
+
+ const enabled =
+ project.preprodSizePrCommentsEnabled ?? project.options?.[ENABLED_KEY] === true;
+
+ const rulesRaw = project.preprodSizePrCommentsRules ?? project.options?.[RULES_KEY];
+ const rules = useMemo(() => {
+ if (Array.isArray(rulesRaw)) {
+ return parseRules(rulesRaw);
+ }
+ if (typeof rulesRaw !== 'string') {
+ return [];
+ }
+ try {
+ return parseRules(JSON.parse(rulesRaw));
+ } catch {
+ return [];
+ }
+ }, [rulesRaw]);
+
+ const config = {enabled, rules};
+
+ const setEnabled = useCallback(
+ (value: boolean) => {
+ addLoadingMessage(t('Saving...'));
+ updateProject.mutate(
+ {preprodSizePrCommentsEnabled: value},
+ {
+ onSuccess: () => {
+ addSuccessMessage(
+ value ? t('PR comments enabled.') : t('PR comments disabled.')
+ );
+ },
+ onError: () => {
+ addErrorMessage(t('Failed to save changes. Please try again.'));
+ },
+ }
+ );
+ },
+ [updateProject]
+ );
+
+ const saveRules = useCallback(
+ (newRules: StatusCheckRule[], successMessage?: string) => {
+ addLoadingMessage(t('Saving...'));
+ updateProject.mutate(
+ {preprodSizePrCommentsRules: newRules as unknown[]},
+ {
+ onSuccess: () => {
+ if (successMessage) {
+ addSuccessMessage(successMessage);
+ }
+ },
+ onError: () => {
+ addErrorMessage(t('Failed to save changes. Please try again.'));
+ },
+ }
+ );
+ },
+ [updateProject]
+ );
+
+ const addRule = useCallback(
+ (rule: StatusCheckRule) => {
+ saveRules([...rules, rule], t('PR comment rule created.'));
+ },
+ [rules, saveRules]
+ );
+
+ const updateRule = useCallback(
+ (id: string, updates: Partial) => {
+ const newRules = rules.map(r => (r.id === id ? {...r, ...updates} : r));
+ saveRules(newRules, t('PR comment rule saved.'));
+ },
+ [rules, saveRules]
+ );
+
+ const deleteRule = useCallback(
+ (id: string) => {
+ const newRules = rules.filter(r => r.id !== id);
+ saveRules(newRules, t('PR comment rule deleted.'));
+ },
+ [rules, saveRules]
+ );
+
+ const createEmptyRule = useCallback((): StatusCheckRule => {
+ return {
+ id: uniqueId(),
+ metric: DEFAULT_METRIC,
+ measurement: DEFAULT_MEASUREMENT,
+ value: 0,
+ filterQuery: '',
+ artifactType: DEFAULT_ARTIFACT_TYPE,
+ };
+ }, []);
+
+ return {
+ config,
+ setEnabled,
+ addRule,
+ updateRule,
+ deleteRule,
+ createEmptyRule,
+ };
+}
From 12784d393c3cccf67b35114419ade02659dd75d6 Mon Sep 17 00:00:00 2001
From: Max Topolsky <30879163+mtopo27@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:52:29 -0400
Subject: [PATCH 2/3] ref(preprod): Share size-analysis rules panel across
status checks and PR comments
The Status Checks and PR Comments size-analysis settings panels were near-verbatim clones, along with their backing hooks. Extract a single config-driven SizeRulesPanel plus a parameterized useSizeRules hook; StatusCheckRules and PrCommentRules become thin wrappers supplying option keys, copy, toasts, the enabled default, and analytics callbacks. Behavior for both panels is unchanged (same option keys, defaults, events, and toasts).
The shared rule editor (StatusCheckRuleForm) previously hardcoded status-check copy, so the PR Comments panel showed 'Fail Status Check When' and a status-check delete confirmation. The form now takes optional copy props (defaulting to the status-check text), and the PR Comments panel supplies PR-appropriate wording and search source.
Also adopt Grid over a hand-rolled styled grid and drop a one-off styled Button wrapper in the empty state, per frontend guidelines.
Refs EME-939
---
.../project/preprod/prCommentRules.tsx | 273 +++++------------
.../project/preprod/sizeRulesPanel.tsx | 221 ++++++++++++++
.../project/preprod/statusCheckRuleForm.tsx | 42 ++-
.../project/preprod/statusCheckRuleItem.tsx | 5 +-
.../project/preprod/statusCheckRules.tsx | 275 +++++-------------
.../project/preprod/usePrCommentRules.ts | 155 ----------
...useStatusCheckRules.ts => useSizeRules.ts} | 91 +++---
7 files changed, 444 insertions(+), 618 deletions(-)
create mode 100644 static/app/views/settings/project/preprod/sizeRulesPanel.tsx
delete mode 100644 static/app/views/settings/project/preprod/usePrCommentRules.ts
rename static/app/views/settings/project/preprod/{useStatusCheckRules.ts => useSizeRules.ts} (54%)
diff --git a/static/app/views/settings/project/preprod/prCommentRules.tsx b/static/app/views/settings/project/preprod/prCommentRules.tsx
index 41886240a301..e1a350511045 100644
--- a/static/app/views/settings/project/preprod/prCommentRules.tsx
+++ b/static/app/views/settings/project/preprod/prCommentRules.tsx
@@ -1,222 +1,81 @@
-import {Fragment, useCallback, useMemo, useState} from 'react';
-import styled from '@emotion/styled';
-
-import seerConfigBugSvg from 'sentry-images/spot/seer-config-bug-1.svg';
-
-import {Button, LinkButton} from '@sentry/scraps/button';
-import {Container, Flex, Stack} from '@sentry/scraps/layout';
-import {Switch} from '@sentry/scraps/switch';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {Panel} from 'sentry/components/panels/panel';
-import {PanelBody} from 'sentry/components/panels/panelBody';
-import {PanelHeader} from 'sentry/components/panels/panelHeader';
-import {IconAdd} from 'sentry/icons';
import {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
-import {useLocation} from 'sentry/utils/useLocation';
-import {useNavigate} from 'sentry/utils/useNavigate';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {useRepositories} from 'sentry/utils/useRepositories';
import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
-import {StatusCheckRuleItem} from './statusCheckRuleItem';
+import {SizeRulesPanel} from './sizeRulesPanel';
import {DEFAULT_ARTIFACT_TYPE} from './types';
-import {usePrCommentRules} from './usePrCommentRules';
export function PrCommentRules() {
const organization = useOrganization();
const {project} = useProjectSettingsOutlet();
- const location = useLocation();
- const navigate = useNavigate();
- const {data: repositories, isPending: isLoadingRepos} = useRepositories({
- orgSlug: organization.slug,
- });
- const {config, setEnabled, addRule, updateRule, deleteRule, createEmptyRule} =
- usePrCommentRules(project);
-
- const [newRuleId, setNewRuleId] = useState(null);
-
- const expandedRuleIds = useMemo(() => {
- const expanded = location.query.expanded;
- if (!expanded) {
- return new Set();
- }
- return new Set(Array.isArray(expanded) ? expanded : [expanded]);
- }, [location.query.expanded]);
- const handleAddRule = () => {
- const newRule = createEmptyRule();
- addRule(newRule);
- trackAnalytics('preprod.settings.pr_comment_rule_created', {
- organization,
- project_slug: project.slug,
- });
- setNewRuleId(newRule.id);
- updateExpandedInUrl([...expandedRuleIds, newRule.id]);
- };
-
- const updateExpandedInUrl = useCallback(
- (expandedIds: string[]) => {
- navigate(
- {
- query: {
- ...location.query,
- expanded: expandedIds,
+ return (
+ {
- const newExpanded = new Set(expandedRuleIds);
- if (isExpanded) {
- newExpanded.add(ruleId);
- } else {
- newExpanded.delete(ruleId);
- if (ruleId === newRuleId) {
- setNewRuleId(null);
- }
- }
- updateExpandedInUrl([...newExpanded]);
- };
-
- const hasRepositories = !isLoadingRepos && repositories && repositories.length > 0;
-
- return (
-
- {t('Size Analysis - PR Comments')}
-
- {hasRepositories ? (
-
-
-
-
- {t('PR Comments Enabled')}
-
-
- {t("Sentry will post PR comments based on your build's app size.")}
-
-
- setEnabled(!config.enabled)}
- aria-label={t('Toggle PR comments')}
- />
-
-
- {config.enabled ? (
-
- {config.rules.length > 0 ? (
-
- {config.rules.map(rule => (
-
- handleToggleExpanded(rule.id, isExpanded)
- }
- onSave={updated => {
- updateRule(rule.id, updated);
- trackAnalytics('preprod.settings.pr_comment_rule_updated', {
- organization,
- project_slug: project.slug,
- metric: updated.metric,
- measurement: updated.measurement,
- artifact_type: updated.artifactType ?? DEFAULT_ARTIFACT_TYPE,
- value: updated.value,
- });
- if (rule.id === newRuleId) {
- setNewRuleId(null);
- }
- }}
- onDelete={() => {
- trackAnalytics('preprod.settings.pr_comment_rule_deleted', {
- organization,
- project_slug: project.slug,
- });
- deleteRule(rule.id);
- if (rule.id === newRuleId) {
- setNewRuleId(null);
- }
- const newExpanded = new Set(expandedRuleIds);
- newExpanded.delete(rule.id);
- updateExpandedInUrl([...newExpanded]);
- }}
- />
- ))}
-
- ) : (
-
-
- {t('No PR comment rules configured. Create one to get started.')}
-
-
- )}
-
-
- } onClick={handleAddRule}>
- {t('Create PR Comment Rule')}
-
-
-
- ) : (
-
-
- {t('Enable PR comments above to configure rules.')}
-
-
- )}
-
- ) : (
-
-
- {t('Get the most out of Size Analysis')}
-
- {t('Connect at least one repository to get Size Analysis PR comments')}
-
-
- {t('Add Repo')}
-
-
-
-
- )}
-
-
+ copy: {
+ panelTitle: t('Size Analysis - PR Comments'),
+ enabledLabel: t('PR Comments Enabled'),
+ enabledDescription: t(
+ "Sentry will post PR comments based on your build's app size."
+ ),
+ toggleAriaLabel: t('Toggle PR comments'),
+ emptyRulesText: t('No PR comment rules configured. Create one to get started.'),
+ disabledHintText: t('Enable PR comments above to configure rules.'),
+ addRuleButtonLabel: t('Create PR Comment Rule'),
+ connectRepoText: t(
+ 'Connect at least one repository to get Size Analysis PR comments'
+ ),
+ form: {
+ headerLabel: t('Comment on PR When'),
+ deleteConfirmHeader: t(
+ 'Are you sure you want to delete this PR comment rule?'
+ ),
+ deleteConfirmMessage: (ruleDescription, valueWithUnit) => (
+
+ Will no longer comment on PRs when {ruleDescription}{' '}
+ surpasses {valueWithUnit}
+
+ ),
+ searchSource: 'preprod_pr_comment_filters',
+ },
+ },
+ analytics: {
+ onCreate: () =>
+ trackAnalytics('preprod.settings.pr_comment_rule_created', {
+ organization,
+ project_slug: project.slug,
+ }),
+ onUpdate: rule =>
+ trackAnalytics('preprod.settings.pr_comment_rule_updated', {
+ organization,
+ project_slug: project.slug,
+ metric: rule.metric,
+ measurement: rule.measurement,
+ artifact_type: rule.artifactType ?? DEFAULT_ARTIFACT_TYPE,
+ value: rule.value,
+ }),
+ onDelete: () =>
+ trackAnalytics('preprod.settings.pr_comment_rule_deleted', {
+ organization,
+ project_slug: project.slug,
+ }),
+ },
+ }}
+ />
);
}
-
-const AddRuleButton = styled(Button)`
- align-self: flex-start;
-`;
-
-const EmptyStateContainer = styled('div')`
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- padding: 56px 48px;
- gap: ${p => p.theme.space.xl};
-`;
-
-const ImageContainer = styled('div')`
- width: 220px;
- height: 220px;
- background-image: url(${seerConfigBugSvg});
- background-size: contain;
- background-position: center;
- background-repeat: no-repeat;
- flex-shrink: 0;
-`;
diff --git a/static/app/views/settings/project/preprod/sizeRulesPanel.tsx b/static/app/views/settings/project/preprod/sizeRulesPanel.tsx
new file mode 100644
index 000000000000..f5d079328403
--- /dev/null
+++ b/static/app/views/settings/project/preprod/sizeRulesPanel.tsx
@@ -0,0 +1,221 @@
+import {Fragment, useCallback, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import seerConfigBugSvg from 'sentry-images/spot/seer-config-bug-1.svg';
+
+import {Button, LinkButton} from '@sentry/scraps/button';
+import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {Switch} from '@sentry/scraps/switch';
+import {Heading, Text} from '@sentry/scraps/text';
+
+import {Panel} from 'sentry/components/panels/panel';
+import {PanelBody} from 'sentry/components/panels/panelBody';
+import {PanelHeader} from 'sentry/components/panels/panelHeader';
+import {IconAdd} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {useRepositories} from 'sentry/utils/useRepositories';
+import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
+
+import type {RuleFormCopy} from './statusCheckRuleForm';
+import {StatusCheckRuleItem} from './statusCheckRuleItem';
+import type {StatusCheckRule} from './types';
+import {type SizeRulesConfig, useSizeRules} from './useSizeRules';
+
+interface SizeRulesPanelConfig {
+ analytics: {
+ onCreate: () => void;
+ onDelete: () => void;
+ onUpdate: (rule: StatusCheckRule) => void;
+ };
+ copy: {
+ addRuleButtonLabel: string;
+ connectRepoText: string;
+ disabledHintText: string;
+ emptyRulesText: string;
+ enabledDescription: string;
+ enabledLabel: string;
+ form: RuleFormCopy;
+ panelTitle: string;
+ toggleAriaLabel: string;
+ };
+ rules: SizeRulesConfig;
+}
+
+interface Props {
+ config: SizeRulesPanelConfig;
+}
+
+export function SizeRulesPanel({config: panelConfig}: Props) {
+ const {copy, analytics} = panelConfig;
+ const organization = useOrganization();
+ const {project} = useProjectSettingsOutlet();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const {data: repositories, isPending: isLoadingRepos} = useRepositories({
+ orgSlug: organization.slug,
+ });
+ const {config, setEnabled, addRule, updateRule, deleteRule, createEmptyRule} =
+ useSizeRules(project, panelConfig.rules);
+
+ const [newRuleId, setNewRuleId] = useState(null);
+
+ const expandedRuleIds = useMemo(() => {
+ const expanded = location.query.expanded;
+ if (!expanded) {
+ return new Set();
+ }
+ return new Set(Array.isArray(expanded) ? expanded : [expanded]);
+ }, [location.query.expanded]);
+
+ const updateExpandedInUrl = useCallback(
+ (expandedIds: string[]) => {
+ navigate(
+ {
+ query: {
+ ...location.query,
+ expanded: expandedIds,
+ },
+ },
+ {replace: true}
+ );
+ },
+ [location.query, navigate]
+ );
+
+ const handleAddRule = () => {
+ const newRule = createEmptyRule();
+ addRule(newRule);
+ analytics.onCreate();
+ setNewRuleId(newRule.id);
+ updateExpandedInUrl([...expandedRuleIds, newRule.id]);
+ };
+
+ const handleToggleExpanded = (ruleId: string, isExpanded: boolean) => {
+ const newExpanded = new Set(expandedRuleIds);
+ if (isExpanded) {
+ newExpanded.add(ruleId);
+ } else {
+ newExpanded.delete(ruleId);
+ if (ruleId === newRuleId) {
+ setNewRuleId(null);
+ }
+ }
+ updateExpandedInUrl([...newExpanded]);
+ };
+
+ const hasRepositories = !isLoadingRepos && repositories && repositories.length > 0;
+
+ return (
+
+ {copy.panelTitle}
+
+ {hasRepositories ? (
+
+
+
+
+ {copy.enabledLabel}
+
+
+ {copy.enabledDescription}
+
+
+ setEnabled(!config.enabled)}
+ aria-label={copy.toggleAriaLabel}
+ />
+
+
+ {config.enabled ? (
+
+ {config.rules.length > 0 ? (
+
+ {config.rules.map(rule => (
+
+ handleToggleExpanded(rule.id, isExpanded)
+ }
+ onSave={updated => {
+ updateRule(rule.id, updated);
+ analytics.onUpdate(updated);
+ if (rule.id === newRuleId) {
+ setNewRuleId(null);
+ }
+ }}
+ onDelete={() => {
+ analytics.onDelete();
+ deleteRule(rule.id);
+ if (rule.id === newRuleId) {
+ setNewRuleId(null);
+ }
+ const newExpanded = new Set(expandedRuleIds);
+ newExpanded.delete(rule.id);
+ updateExpandedInUrl([...newExpanded]);
+ }}
+ />
+ ))}
+
+ ) : (
+
+
+ {copy.emptyRulesText}
+
+
+ )}
+
+
+ } onClick={handleAddRule}>
+ {copy.addRuleButtonLabel}
+
+
+
+ ) : (
+
+
+ {copy.disabledHintText}
+
+
+ )}
+
+ ) : (
+
+
+ {t('Get the most out of Size Analysis')}
+ {copy.connectRepoText}
+
+ {t('Add Repo')}
+
+
+
+
+ )}
+
+
+ );
+}
+
+const ImageContainer = styled('div')`
+ width: 220px;
+ height: 220px;
+ background-image: url(${seerConfigBugSvg});
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ flex-shrink: 0;
+`;
diff --git a/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx b/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx
index d1dc34a73c2d..f139b8d07d6f 100644
--- a/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx
+++ b/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx
@@ -1,4 +1,4 @@
-import {useState} from 'react';
+import {type ReactNode, useState} from 'react';
import styled from '@emotion/styled';
import {Button} from '@sentry/scraps/button';
@@ -27,13 +27,38 @@ import {
STATUS_CHECK_ALLOWED_FILTER_KEYS,
} from './types';
+export interface RuleFormCopy {
+ deleteConfirmHeader: string;
+ deleteConfirmMessage: (ruleDescription: string, valueWithUnit: string) => ReactNode;
+ headerLabel: string;
+ searchSource: string;
+}
+
+const DEFAULT_COPY: RuleFormCopy = {
+ headerLabel: t('Fail Status Check When'),
+ deleteConfirmHeader: t('Are you sure you want to delete this status check rule?'),
+ deleteConfirmMessage: (ruleDescription, valueWithUnit) => (
+
+ Will no longer fail status checks when {ruleDescription} surpasses{' '}
+ {valueWithUnit}
+
+ ),
+ searchSource: 'preprod_status_check_filters',
+};
+
interface Props {
onDelete: () => void;
onSave: (rule: StatusCheckRule) => void;
rule: StatusCheckRule;
+ copy?: RuleFormCopy;
}
-export function StatusCheckRuleForm({rule, onSave, onDelete}: Props) {
+export function StatusCheckRuleForm({
+ rule,
+ onSave,
+ onDelete,
+ copy = DEFAULT_COPY,
+}: Props) {
const {project} = useProjectSettingsOutlet();
const [metric, setMetric] = useState(rule.metric);
const [measurement, setMeasurement] = useState(rule.measurement);
@@ -78,15 +103,10 @@ export function StatusCheckRuleForm({rule, onSave, onDelete}: Props) {
openConfirmModal({
header: (
- {t('Are you sure you want to delete this status check rule?')}
+ {copy.deleteConfirmHeader}
),
- message: (
-
- Will no longer fail status checks when {ruleDescription}{' '}
- surpasses {valueWithUnit}
-
- ),
+ message: copy.deleteConfirmMessage(ruleDescription, valueWithUnit),
confirmText: t('Delete Rule'),
priority: 'danger',
onConfirm: onDelete,
@@ -95,7 +115,7 @@ export function StatusCheckRuleForm({rule, onSave, onDelete}: Props) {
return (
- {t('Fail Status Check When')}
+ {copy.headerLabel}
handleQueryChange(query)}
- searchSource="preprod_status_check_filters"
+ searchSource={copy.searchSource}
portalTarget={document.body}
disallowFreeText
disallowHas
diff --git a/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx b/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx
index 1a08457265ec..296f300e797d 100644
--- a/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx
+++ b/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx
@@ -11,7 +11,7 @@ import {
} from 'sentry/components/searchSyntax/parser';
import {IconChevron} from 'sentry/icons';
-import {StatusCheckRuleForm} from './statusCheckRuleForm';
+import {type RuleFormCopy, StatusCheckRuleForm} from './statusCheckRuleForm';
import type {StatusCheckFilter, StatusCheckRule} from './types';
import {
bytesToMB,
@@ -27,6 +27,7 @@ interface Props {
onSave: (rule: StatusCheckRule) => void;
onToggleExpanded: (isExpanded: boolean) => void;
rule: StatusCheckRule;
+ formCopy?: RuleFormCopy;
}
function FilterSummary({filters}: {filters: StatusCheckFilter[]}) {
@@ -61,6 +62,7 @@ export function StatusCheckRuleItem({
onDelete,
onToggleExpanded,
isExpanded,
+ formCopy,
}: Props) {
const filters = parseFiltersForDisplay(rule.filterQuery);
@@ -91,6 +93,7 @@ export function StatusCheckRuleItem({
rule={rule}
onSave={onSave}
onDelete={onDelete}
+ copy={formCopy}
/>
)}
diff --git a/static/app/views/settings/project/preprod/statusCheckRules.tsx b/static/app/views/settings/project/preprod/statusCheckRules.tsx
index 2c0ba172b9bb..364cf24e976b 100644
--- a/static/app/views/settings/project/preprod/statusCheckRules.tsx
+++ b/static/app/views/settings/project/preprod/statusCheckRules.tsx
@@ -1,222 +1,83 @@
-import {Fragment, useCallback, useMemo, useState} from 'react';
-import styled from '@emotion/styled';
-
-import seerConfigBugSvg from 'sentry-images/spot/seer-config-bug-1.svg';
-
-import {Button, LinkButton} from '@sentry/scraps/button';
-import {Container, Flex, Stack} from '@sentry/scraps/layout';
-import {Switch} from '@sentry/scraps/switch';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {Panel} from 'sentry/components/panels/panel';
-import {PanelBody} from 'sentry/components/panels/panelBody';
-import {PanelHeader} from 'sentry/components/panels/panelHeader';
-import {IconAdd} from 'sentry/icons';
import {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
-import {useLocation} from 'sentry/utils/useLocation';
-import {useNavigate} from 'sentry/utils/useNavigate';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {useRepositories} from 'sentry/utils/useRepositories';
import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
-import {StatusCheckRuleItem} from './statusCheckRuleItem';
+import {SizeRulesPanel} from './sizeRulesPanel';
import {DEFAULT_ARTIFACT_TYPE} from './types';
-import {useStatusCheckRules} from './useStatusCheckRules';
export function StatusCheckRules() {
const organization = useOrganization();
const {project} = useProjectSettingsOutlet();
- const location = useLocation();
- const navigate = useNavigate();
- const {data: repositories, isPending: isLoadingRepos} = useRepositories({
- orgSlug: organization.slug,
- });
- const {config, setEnabled, addRule, updateRule, deleteRule, createEmptyRule} =
- useStatusCheckRules(project);
-
- const [newRuleId, setNewRuleId] = useState(null);
-
- const expandedRuleIds = useMemo(() => {
- const expanded = location.query.expanded;
- if (!expanded) {
- return new Set();
- }
- return new Set(Array.isArray(expanded) ? expanded : [expanded]);
- }, [location.query.expanded]);
- const handleAddRule = () => {
- const newRule = createEmptyRule();
- addRule(newRule);
- trackAnalytics('preprod.settings.status_check_rule_created', {
- organization,
- project_slug: project.slug,
- });
- setNewRuleId(newRule.id);
- updateExpandedInUrl([...expandedRuleIds, newRule.id]);
- };
-
- const updateExpandedInUrl = useCallback(
- (expandedIds: string[]) => {
- navigate(
- {
- query: {
- ...location.query,
- expanded: expandedIds,
+ return (
+ {
- const newExpanded = new Set(expandedRuleIds);
- if (isExpanded) {
- newExpanded.add(ruleId);
- } else {
- newExpanded.delete(ruleId);
- if (ruleId === newRuleId) {
- setNewRuleId(null);
- }
- }
- updateExpandedInUrl([...newExpanded]);
- };
-
- const hasRepositories = !isLoadingRepos && repositories && repositories.length > 0;
-
- return (
-
- {t('Size Analysis - Status Checks')}
-
- {hasRepositories ? (
-
-
-
-
- {t('Status Checks Enabled')}
-
-
- {t("Sentry will post status checks based on your build's app size.")}
-
-
- setEnabled(!config.enabled)}
- aria-label={t('Toggle status checks')}
- />
-
-
- {config.enabled ? (
-
- {config.rules.length > 0 ? (
-
- {config.rules.map(rule => (
-
- handleToggleExpanded(rule.id, isExpanded)
- }
- onSave={updated => {
- updateRule(rule.id, updated);
- trackAnalytics('preprod.settings.status_check_rule_updated', {
- organization,
- project_slug: project.slug,
- metric: updated.metric,
- measurement: updated.measurement,
- artifact_type: updated.artifactType ?? DEFAULT_ARTIFACT_TYPE,
- value: updated.value,
- });
- if (rule.id === newRuleId) {
- setNewRuleId(null);
- }
- }}
- onDelete={() => {
- trackAnalytics('preprod.settings.status_check_rule_deleted', {
- organization,
- project_slug: project.slug,
- });
- deleteRule(rule.id);
- if (rule.id === newRuleId) {
- setNewRuleId(null);
- }
- const newExpanded = new Set(expandedRuleIds);
- newExpanded.delete(rule.id);
- updateExpandedInUrl([...newExpanded]);
- }}
- />
- ))}
-
- ) : (
-
-
- {t('No status check rules configured. Create one to get started.')}
-
-
- )}
-
-
- } onClick={handleAddRule}>
- {t('Create Status Check Rule')}
-
-
-
- ) : (
-
-
- {t('Enable status checks above to configure rules.')}
-
-
- )}
-
- ) : (
-
-
- {t('Get the most out of Size Analysis')}
-
- {t('Connect at least one repository to get Size Analysis status checks')}
-
-
- {t('Add Repo')}
-
-
-
-
- )}
-
-
+ copy: {
+ panelTitle: t('Size Analysis - Status Checks'),
+ enabledLabel: t('Status Checks Enabled'),
+ enabledDescription: t(
+ "Sentry will post status checks based on your build's app size."
+ ),
+ toggleAriaLabel: t('Toggle status checks'),
+ emptyRulesText: t(
+ 'No status check rules configured. Create one to get started.'
+ ),
+ disabledHintText: t('Enable status checks above to configure rules.'),
+ addRuleButtonLabel: t('Create Status Check Rule'),
+ connectRepoText: t(
+ 'Connect at least one repository to get Size Analysis status checks'
+ ),
+ form: {
+ headerLabel: t('Fail Status Check When'),
+ deleteConfirmHeader: t(
+ 'Are you sure you want to delete this status check rule?'
+ ),
+ deleteConfirmMessage: (ruleDescription, valueWithUnit) => (
+
+ Will no longer fail status checks when {ruleDescription}{' '}
+ surpasses {valueWithUnit}
+
+ ),
+ searchSource: 'preprod_status_check_filters',
+ },
+ },
+ analytics: {
+ onCreate: () =>
+ trackAnalytics('preprod.settings.status_check_rule_created', {
+ organization,
+ project_slug: project.slug,
+ }),
+ onUpdate: rule =>
+ trackAnalytics('preprod.settings.status_check_rule_updated', {
+ organization,
+ project_slug: project.slug,
+ metric: rule.metric,
+ measurement: rule.measurement,
+ artifact_type: rule.artifactType ?? DEFAULT_ARTIFACT_TYPE,
+ value: rule.value,
+ }),
+ onDelete: () =>
+ trackAnalytics('preprod.settings.status_check_rule_deleted', {
+ organization,
+ project_slug: project.slug,
+ }),
+ },
+ }}
+ />
);
}
-
-const AddRuleButton = styled(Button)`
- align-self: flex-start;
-`;
-
-const EmptyStateContainer = styled('div')`
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- padding: 56px 48px;
- gap: ${p => p.theme.space.xl};
-`;
-
-const ImageContainer = styled('div')`
- width: 220px;
- height: 220px;
- background-image: url(${seerConfigBugSvg});
- background-size: contain;
- background-position: center;
- background-repeat: no-repeat;
- flex-shrink: 0;
-`;
diff --git a/static/app/views/settings/project/preprod/usePrCommentRules.ts b/static/app/views/settings/project/preprod/usePrCommentRules.ts
deleted file mode 100644
index be8ca0cde132..000000000000
--- a/static/app/views/settings/project/preprod/usePrCommentRules.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import {useCallback, useMemo} from 'react';
-
-import {
- addErrorMessage,
- addLoadingMessage,
- addSuccessMessage,
-} from 'sentry/actionCreators/indicator';
-import {t} from 'sentry/locale';
-import type {DetailedProject} from 'sentry/types/project';
-import {uniqueId} from 'sentry/utils/guid';
-import {useUpdateProject} from 'sentry/utils/project/useUpdateProject';
-
-import {
- DEFAULT_ARTIFACT_TYPE,
- DEFAULT_MEASUREMENT_TYPE,
- DEFAULT_METRIC_TYPE,
- toArtifactType,
- toMeasurementType,
- toMetricType,
- type StatusCheckRule,
-} from './types';
-
-const ENABLED_KEY = 'sentry:preprod_size_pr_comments_enabled';
-const RULES_KEY = 'sentry:preprod_size_pr_comments_rules';
-
-const DEFAULT_METRIC = DEFAULT_METRIC_TYPE;
-const DEFAULT_MEASUREMENT = DEFAULT_MEASUREMENT_TYPE;
-
-function parseRules(raw: unknown): StatusCheckRule[] {
- if (!Array.isArray(raw)) {
- return [];
- }
- return raw
- .filter((r): r is Record => !!r && typeof r.id === 'string')
- .map(r => {
- const metric = toMetricType(r.metric, DEFAULT_METRIC);
- const measurement = toMeasurementType(r.measurement, DEFAULT_MEASUREMENT);
- const artifactType = toArtifactType(r.artifactType);
- return {
- id: r.id as string,
- metric,
- measurement,
- value: typeof r.value === 'number' ? r.value : 0,
- filterQuery: typeof r.filterQuery === 'string' ? r.filterQuery : '',
- artifactType,
- };
- });
-}
-
-export function usePrCommentRules(project: DetailedProject) {
- const updateProject = useUpdateProject(project);
-
- const enabled =
- project.preprodSizePrCommentsEnabled ?? project.options?.[ENABLED_KEY] === true;
-
- const rulesRaw = project.preprodSizePrCommentsRules ?? project.options?.[RULES_KEY];
- const rules = useMemo(() => {
- if (Array.isArray(rulesRaw)) {
- return parseRules(rulesRaw);
- }
- if (typeof rulesRaw !== 'string') {
- return [];
- }
- try {
- return parseRules(JSON.parse(rulesRaw));
- } catch {
- return [];
- }
- }, [rulesRaw]);
-
- const config = {enabled, rules};
-
- const setEnabled = useCallback(
- (value: boolean) => {
- addLoadingMessage(t('Saving...'));
- updateProject.mutate(
- {preprodSizePrCommentsEnabled: value},
- {
- onSuccess: () => {
- addSuccessMessage(
- value ? t('PR comments enabled.') : t('PR comments disabled.')
- );
- },
- onError: () => {
- addErrorMessage(t('Failed to save changes. Please try again.'));
- },
- }
- );
- },
- [updateProject]
- );
-
- const saveRules = useCallback(
- (newRules: StatusCheckRule[], successMessage?: string) => {
- addLoadingMessage(t('Saving...'));
- updateProject.mutate(
- {preprodSizePrCommentsRules: newRules as unknown[]},
- {
- onSuccess: () => {
- if (successMessage) {
- addSuccessMessage(successMessage);
- }
- },
- onError: () => {
- addErrorMessage(t('Failed to save changes. Please try again.'));
- },
- }
- );
- },
- [updateProject]
- );
-
- const addRule = useCallback(
- (rule: StatusCheckRule) => {
- saveRules([...rules, rule], t('PR comment rule created.'));
- },
- [rules, saveRules]
- );
-
- const updateRule = useCallback(
- (id: string, updates: Partial) => {
- const newRules = rules.map(r => (r.id === id ? {...r, ...updates} : r));
- saveRules(newRules, t('PR comment rule saved.'));
- },
- [rules, saveRules]
- );
-
- const deleteRule = useCallback(
- (id: string) => {
- const newRules = rules.filter(r => r.id !== id);
- saveRules(newRules, t('PR comment rule deleted.'));
- },
- [rules, saveRules]
- );
-
- const createEmptyRule = useCallback((): StatusCheckRule => {
- return {
- id: uniqueId(),
- metric: DEFAULT_METRIC,
- measurement: DEFAULT_MEASUREMENT,
- value: 0,
- filterQuery: '',
- artifactType: DEFAULT_ARTIFACT_TYPE,
- };
- }, []);
-
- return {
- config,
- setEnabled,
- addRule,
- updateRule,
- deleteRule,
- createEmptyRule,
- };
-}
diff --git a/static/app/views/settings/project/preprod/useStatusCheckRules.ts b/static/app/views/settings/project/preprod/useSizeRules.ts
similarity index 54%
rename from static/app/views/settings/project/preprod/useStatusCheckRules.ts
rename to static/app/views/settings/project/preprod/useSizeRules.ts
index e8816dc9938f..9c1bd7bbccde 100644
--- a/static/app/views/settings/project/preprod/useStatusCheckRules.ts
+++ b/static/app/views/settings/project/preprod/useSizeRules.ts
@@ -20,11 +20,29 @@ import {
type StatusCheckRule,
} from './types';
-const ENABLED_KEY = 'sentry:preprod_size_status_checks_enabled';
-const RULES_KEY = 'sentry:preprod_size_status_checks_rules';
-
-const DEFAULT_METRIC = DEFAULT_METRIC_TYPE;
-const DEFAULT_MEASUREMENT = DEFAULT_MEASUREMENT_TYPE;
+type EnabledField = 'preprodSizeStatusChecksEnabled' | 'preprodSizePrCommentsEnabled';
+
+type RulesField = 'preprodSizeStatusChecksRules' | 'preprodSizePrCommentsRules';
+
+export interface SizeRulesConfig {
+ /** Whether the feature is enabled when neither field nor option is set. */
+ defaultEnabled: boolean;
+ /** Top-level project field that mirrors the enabled flag (optimistic update). */
+ enabledField: EnabledField;
+ /** Project option key that stores the enabled flag (server response). */
+ enabledOptionKey: string;
+ /** Top-level project field that mirrors the rules array (optimistic update). */
+ rulesField: RulesField;
+ /** Project option key that stores the rules array (server response). */
+ rulesOptionKey: string;
+ toasts: {
+ created: string;
+ deleted: string;
+ disabled: string;
+ enabled: string;
+ saved: string;
+ };
+}
function parseRules(raw: unknown): StatusCheckRule[] {
if (!Array.isArray(raw)) {
@@ -33,8 +51,8 @@ function parseRules(raw: unknown): StatusCheckRule[] {
return raw
.filter((r): r is Record => !!r && typeof r.id === 'string')
.map(r => {
- const metric = toMetricType(r.metric, DEFAULT_METRIC);
- const measurement = toMeasurementType(r.measurement, DEFAULT_MEASUREMENT);
+ const metric = toMetricType(r.metric, DEFAULT_METRIC_TYPE);
+ const measurement = toMeasurementType(r.measurement, DEFAULT_MEASUREMENT_TYPE);
const artifactType = toArtifactType(r.artifactType);
return {
id: r.id as string,
@@ -47,14 +65,18 @@ function parseRules(raw: unknown): StatusCheckRule[] {
});
}
-export function useStatusCheckRules(project: DetailedProject) {
+export function useSizeRules(project: DetailedProject, config: SizeRulesConfig) {
const updateProject = useUpdateProject(project);
- // Check top-level field first (optimistic update), fallback to options (server response)
+ // Check top-level field first (optimistic update), fallback to options (server
+ // response), then to the configured default.
+ const enabledOption = project.options?.[config.enabledOptionKey];
const enabled =
- project.preprodSizeStatusChecksEnabled ?? project.options?.[ENABLED_KEY] !== false;
+ project[config.enabledField] ??
+ (enabledOption === undefined ? config.defaultEnabled : enabledOption === true);
- const rulesRaw = project.preprodSizeStatusChecksRules ?? project.options?.[RULES_KEY];
+ const rulesRaw =
+ (project[config.rulesField] as unknown) ?? project.options?.[config.rulesOptionKey];
const rules = useMemo(() => {
if (Array.isArray(rulesRaw)) {
return parseRules(rulesRaw);
@@ -69,33 +91,28 @@ export function useStatusCheckRules(project: DetailedProject) {
}
}, [rulesRaw]);
- const config = {enabled, rules};
+ const settingConfig = {enabled, rules};
const setEnabled = useCallback(
(value: boolean) => {
addLoadingMessage(t('Saving...'));
- updateProject.mutate(
- {preprodSizeStatusChecksEnabled: value},
- {
- onSuccess: () => {
- addSuccessMessage(
- value ? t('Status checks enabled.') : t('Status checks disabled.')
- );
- },
- onError: () => {
- addErrorMessage(t('Failed to save changes. Please try again.'));
- },
- }
- );
+ updateProject.mutate({[config.enabledField]: value} as Partial, {
+ onSuccess: () => {
+ addSuccessMessage(value ? config.toasts.enabled : config.toasts.disabled);
+ },
+ onError: () => {
+ addErrorMessage(t('Failed to save changes. Please try again.'));
+ },
+ });
},
- [updateProject]
+ [updateProject, config.enabledField, config.toasts]
);
const saveRules = useCallback(
(newRules: StatusCheckRule[], successMessage?: string) => {
addLoadingMessage(t('Saving...'));
updateProject.mutate(
- {preprodSizeStatusChecksRules: newRules as unknown[]},
+ {[config.rulesField]: newRules as unknown[]} as Partial,
{
onSuccess: () => {
if (successMessage) {
@@ -108,37 +125,37 @@ export function useStatusCheckRules(project: DetailedProject) {
}
);
},
- [updateProject]
+ [updateProject, config.rulesField]
);
const addRule = useCallback(
(rule: StatusCheckRule) => {
- saveRules([...rules, rule], t('Status check rule created.'));
+ saveRules([...rules, rule], config.toasts.created);
},
- [rules, saveRules]
+ [rules, saveRules, config.toasts.created]
);
const updateRule = useCallback(
(id: string, updates: Partial) => {
const newRules = rules.map(r => (r.id === id ? {...r, ...updates} : r));
- saveRules(newRules, t('Status check rule saved.'));
+ saveRules(newRules, config.toasts.saved);
},
- [rules, saveRules]
+ [rules, saveRules, config.toasts.saved]
);
const deleteRule = useCallback(
(id: string) => {
const newRules = rules.filter(r => r.id !== id);
- saveRules(newRules, t('Status check rule deleted.'));
+ saveRules(newRules, config.toasts.deleted);
},
- [rules, saveRules]
+ [rules, saveRules, config.toasts.deleted]
);
const createEmptyRule = useCallback((): StatusCheckRule => {
return {
id: uniqueId(),
- metric: DEFAULT_METRIC,
- measurement: DEFAULT_MEASUREMENT,
+ metric: DEFAULT_METRIC_TYPE,
+ measurement: DEFAULT_MEASUREMENT_TYPE,
value: 0,
filterQuery: '',
artifactType: DEFAULT_ARTIFACT_TYPE,
@@ -146,7 +163,7 @@ export function useStatusCheckRules(project: DetailedProject) {
}, []);
return {
- config,
+ config: settingConfig,
setEnabled,
addRule,
updateRule,
From bd6a71cb788dc62857f3ef96ff6652de4a198a4f Mon Sep 17 00:00:00 2001
From: Max Topolsky <30879163+mtopo27@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:07:23 -0400
Subject: [PATCH 3/3] test(preprod): Cover size-rules enabled fallback for both
panels
Add component tests for the useSizeRules enabled fallback when the explicit top-level field is absent: option-key true/false for PR comments, and default-on plus option-key override for status checks. statusCheckRules had no spec previously.
Refs EME-939
---
.../project/preprod/prCommentRules.spec.tsx | 48 +++++++++
.../project/preprod/statusCheckRules.spec.tsx | 99 +++++++++++++++++++
2 files changed, 147 insertions(+)
create mode 100644 static/app/views/settings/project/preprod/statusCheckRules.spec.tsx
diff --git a/static/app/views/settings/project/preprod/prCommentRules.spec.tsx b/static/app/views/settings/project/preprod/prCommentRules.spec.tsx
index 88fa1a28aa9d..e9233228e93c 100644
--- a/static/app/views/settings/project/preprod/prCommentRules.spec.tsx
+++ b/static/app/views/settings/project/preprod/prCommentRules.spec.tsx
@@ -79,6 +79,54 @@ describe('PrCommentRules', () => {
).toBeInTheDocument();
});
+ it('reflects an enabled project from the option key when the explicit field is absent', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {'sentry:preprod_size_pr_comments_enabled': true},
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).toBeChecked();
+ expect(
+ screen.getByRole('button', {name: 'Create PR Comment Rule'})
+ ).toBeInTheDocument();
+ });
+
+ it('reflects a disabled project from the option key when the explicit field is absent', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {'sentry:preprod_size_pr_comments_enabled': false},
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).not.toBeChecked();
+ expect(
+ screen.getByText('Enable PR comments above to configure rules.')
+ ).toBeInTheDocument();
+ });
+
it('enables PR comments when toggled on', async () => {
mockRepositories();
const project = DetailedProjectFixture({options: {}});
diff --git a/static/app/views/settings/project/preprod/statusCheckRules.spec.tsx b/static/app/views/settings/project/preprod/statusCheckRules.spec.tsx
new file mode 100644
index 000000000000..70a6621f6952
--- /dev/null
+++ b/static/app/views/settings/project/preprod/statusCheckRules.spec.tsx
@@ -0,0 +1,99 @@
+import {DetailedProjectFixture} from 'sentry-fixture/project';
+import {RepositoryFixture} from 'sentry-fixture/repository';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {StatusCheckRules} from 'sentry/views/settings/project/preprod/statusCheckRules';
+
+describe('StatusCheckRules', () => {
+ const {organization} = initializeOrg();
+ const initialRouterConfig = {
+ location: {
+ pathname: `/settings/projects/test-project/preprod/`,
+ },
+ route: '/settings/projects/:projectId/preprod/',
+ };
+
+ function mockRepositories(repositories = [RepositoryFixture()]) {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/repos/`,
+ body: repositories,
+ });
+ }
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('renders enabled by default when the project has no preprod options', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({options: {}});
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle status checks'})
+ ).toBeChecked();
+ expect(
+ screen.getByText('No status check rules configured. Create one to get started.')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {name: 'Create Status Check Rule'})
+ ).toBeInTheDocument();
+ });
+
+ it('reflects an enabled project from the explicit field', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizeStatusChecksEnabled: true,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle status checks'})
+ ).toBeChecked();
+ });
+
+ it('reflects a disabled project from the option key when the explicit field is absent', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {'sentry:preprod_size_status_checks_enabled': false},
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle status checks'})
+ ).not.toBeChecked();
+ expect(
+ screen.getByText('Enable status checks above to configure rules.')
+ ).toBeInTheDocument();
+ });
+});