From b48552c97fb6715bc4059c8b8b31b2ed6e721f10 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Mon, 23 Mar 2026 09:32:55 -0700
Subject: [PATCH 01/29] feat(seer): Seer SCM Overview
---
.../scmIntegrationTree/providerConfigLink.tsx | 4 +-
.../useScmIntegrationTreeData.ts | 28 +-
...nizationsConfigIntegrationsQueryOptions.ts | 22 ++
.../organizationsIntegrationsQueryOptions.ts | 33 ++
static/app/utils/api/apiFetch.tsx | 17 +-
.../settings/seer/overview/components.tsx | 83 +++++
.../overview/scmOverviewSection.stories.tsx | 138 +++++++++
.../seer/overview/scmOverviewSection.tsx | 285 ++++++++++++++++++
.../gsApp/views/seerAutomation/settings.tsx | 10 +-
9 files changed, 602 insertions(+), 18 deletions(-)
create mode 100644 static/app/endpoints/organizations/organizationsConfigIntegrationsQueryOptions.ts
create mode 100644 static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts
create mode 100644 static/app/views/settings/seer/overview/components.tsx
create mode 100644 static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx
create mode 100644 static/app/views/settings/seer/overview/scmOverviewSection.tsx
diff --git a/static/app/components/repositories/scmIntegrationTree/providerConfigLink.tsx b/static/app/components/repositories/scmIntegrationTree/providerConfigLink.tsx
index 4b0d2aef0b1635..7574ea512efb4b 100644
--- a/static/app/components/repositories/scmIntegrationTree/providerConfigLink.tsx
+++ b/static/app/components/repositories/scmIntegrationTree/providerConfigLink.tsx
@@ -6,7 +6,9 @@ import {IconOpen} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {OrganizationIntegration} from 'sentry/types/integrations';
-function getProviderConfigUrl(integration: OrganizationIntegration): string | null {
+export function getProviderConfigUrl(
+ integration: OrganizationIntegration
+): string | null {
const {externalId, provider, domainName, accountType} = integration;
if (!externalId) {
return null;
diff --git a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts
index 82bff6a99b2a54..1235114f248402 100644
--- a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts
+++ b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts
@@ -1,6 +1,8 @@
import {useEffect, useMemo} from 'react';
import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories';
+import {organizationConfigIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsConfigIntegrationsQueryOptions';
+import {organizationIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsIntegrationsQueryOptions';
import type {
IntegrationProvider,
IntegrationRepository,
@@ -29,13 +31,7 @@ export function useScmIntegrationTreeData(): ScmIntegrationTreeData {
// 1. Fetch all integration providers and filter to SCM
const providersQuery = useQuery(
- apiOptions.as<{providers: IntegrationProvider[]}>()(
- '/organizations/$organizationIdOrSlug/config/integrations/',
- {
- path: {organizationIdOrSlug: organization.slug},
- staleTime: 0,
- }
- )
+ organizationConfigIntegrationsQueryOptions({organization})
);
const scmProviders = useMemo(
@@ -53,13 +49,10 @@ export function useScmIntegrationTreeData(): ScmIntegrationTreeData {
// 2. Fetch installed integrations and filter to SCM providers
const integrationsQuery = useQuery(
- apiOptions.as()(
- '/organizations/$organizationIdOrSlug/integrations/',
- {
- path: {organizationIdOrSlug: organization.slug},
- staleTime: 0,
- }
- )
+ organizationIntegrationsQueryOptions({
+ organization,
+ features: ['commits'],
+ })
);
const scmIntegrations = useMemo(
@@ -78,6 +71,7 @@ export function useScmIntegrationTreeData(): ScmIntegrationTreeData {
isFetchingNextPage: reposIsFetchingNextPage,
fetchNextPage: reposFetchNextPage,
isError: reposIsError,
+ refetch: reposRefetch,
} = useInfiniteQuery(reposQueryOptions);
useEffect(() => {
@@ -143,7 +137,11 @@ export function useScmIntegrationTreeData(): ScmIntegrationTreeData {
scmIntegrations,
connectedRepos,
connectedIdentifiers,
- refetchIntegrations: integrationsQuery.refetch,
+ refetchIntegrations: () => {
+ providersQuery.refetch();
+ integrationsQuery.refetch();
+ reposRefetch();
+ },
reposByIntegrationId,
reposPendingByIntegrationId,
reposQueryKey: reposQueryOptions.queryKey,
diff --git a/static/app/endpoints/organizations/organizationsConfigIntegrationsQueryOptions.ts b/static/app/endpoints/organizations/organizationsConfigIntegrationsQueryOptions.ts
new file mode 100644
index 00000000000000..880ad6de11afb5
--- /dev/null
+++ b/static/app/endpoints/organizations/organizationsConfigIntegrationsQueryOptions.ts
@@ -0,0 +1,22 @@
+import type {IntegrationProvider} from 'sentry/types/integrations';
+import type {Organization} from 'sentry/types/organization';
+import {apiOptions} from 'sentry/utils/api/apiOptions';
+
+export function organizationConfigIntegrationsQueryOptions({
+ organization,
+ providerKey,
+ staleTime = 60_000,
+}: {
+ organization: Organization;
+ providerKey?: string;
+ staleTime?: number;
+}) {
+ return apiOptions.as<{providers: IntegrationProvider[]}>()(
+ '/organizations/$organizationIdOrSlug/config/integrations/',
+ {
+ path: {organizationIdOrSlug: organization.slug},
+ query: {providerKey},
+ staleTime,
+ }
+ );
+}
diff --git a/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts b/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts
new file mode 100644
index 00000000000000..72d5bd85535592
--- /dev/null
+++ b/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts
@@ -0,0 +1,33 @@
+import type {OrganizationIntegration} from 'sentry/types/integrations';
+import type {Organization} from 'sentry/types/organization';
+import {apiOptions} from 'sentry/utils/api/apiOptions';
+
+export function organizationIntegrationsQueryOptions({
+ cursor,
+ features = [],
+ includeConfig = false,
+ organization,
+ providerKey,
+ staleTime = 60_000,
+}: {
+ organization: Organization;
+ cursor?: string;
+ features?: string[];
+ includeConfig?: boolean;
+ providerKey?: string;
+ staleTime?: number;
+}) {
+ return apiOptions.as()(
+ '/organizations/$organizationIdOrSlug/integrations/',
+ {
+ path: {organizationIdOrSlug: organization.slug},
+ query: {
+ cursor,
+ features,
+ includeConfig: includeConfig ? 1 : 0,
+ providerKey,
+ },
+ staleTime,
+ }
+ );
+}
diff --git a/static/app/utils/api/apiFetch.tsx b/static/app/utils/api/apiFetch.tsx
index b4d49304267a77..5ec60e1e43e6ff 100644
--- a/static/app/utils/api/apiFetch.tsx
+++ b/static/app/utils/api/apiFetch.tsx
@@ -1,4 +1,5 @@
-import type {QueryFunctionContext} from '@tanstack/react-query';
+import {useEffect} from 'react';
+import type {QueryFunctionContext, UseInfiniteQueryResult} from '@tanstack/react-query';
import {parseQueryKey} from 'sentry/utils/api/apiQueryKey';
import type {ApiQueryKey, InfiniteApiQueryKey} from 'sentry/utils/api/apiQueryKey';
@@ -40,6 +41,20 @@ export async function apiFetch(
};
}
+export function useFetchAllPages({
+ result,
+ enabled = true,
+}: {
+ result: UseInfiniteQueryResult;
+ enabled?: boolean;
+}) {
+ const {fetchNextPage, hasNextPage, isError, isFetchingNextPage} = result;
+ useEffect(() => {
+ if (enabled && !isError && !isFetchingNextPage && hasNextPage) {
+ fetchNextPage();
+ }
+ }, [enabled, hasNextPage, fetchNextPage, isError, isFetchingNextPage]);
+}
export async function apiFetchInfinite(
context: QueryFunctionContext
): Promise> {
diff --git a/static/app/views/settings/seer/overview/components.tsx b/static/app/views/settings/seer/overview/components.tsx
new file mode 100644
index 00000000000000..ca0e8b8287dacb
--- /dev/null
+++ b/static/app/views/settings/seer/overview/components.tsx
@@ -0,0 +1,83 @@
+import {type ReactNode} from 'react';
+
+import {Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {Heading, Text} from '@sentry/scraps/text';
+
+export function SeerOverview({children}: {children: ReactNode}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Section({children}: {children?: ReactNode}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SectionHeader({children, title}: {title: string; children?: ReactNode}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function Stat({value, label}: {label: string; value: string | number}) {
+ return (
+
+
+ {label}
+
+
+
+ {value}
+
+
+
+ );
+}
+
+function ActionButton({children}: {children: ReactNode}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function formatStatValue(value: number, outOf: number | undefined, isLoading: boolean) {
+ if (isLoading) {
+ return '\u2014';
+ }
+ return outOf === undefined ? value : `${value}\u2009/\u2009${outOf}`;
+}
+
+SeerOverview.Section = Section;
+SeerOverview.SectionHeader = SectionHeader;
+SeerOverview.Stat = Stat;
+SeerOverview.ActionButton = ActionButton;
+SeerOverview.formatStatValue = formatStatValue;
diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx
new file mode 100644
index 00000000000000..272d6aa8caea4f
--- /dev/null
+++ b/static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx
@@ -0,0 +1,138 @@
+import {Fragment} from 'react';
+
+import {Button} from '@sentry/scraps/button';
+import {ExternalLink} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
+
+import {IconAdd} from 'sentry/icons';
+import {t, tct, tn} from 'sentry/locale';
+import * as Storybook from 'sentry/stories';
+import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
+
+export default Storybook.story('SCMOverviewSection', story => {
+ story('Overview', () => (
+
+
+ The displays Source Code
+ Management integration status in the Seer settings overview. It fetches SCM
+ providers, installed integrations, and connected repositories to show a summary
+ stat and actions.
+
+
+ The component internally calls useScmIntegrationTreeData() to load
+ data — the stories below illustrate each visual state by rendering the underlying{' '}
+ SeerOverview primitives directly.
+
+
+ ));
+
+ story('Loading', () => (
+
+
+
+
+ {t('Loading…')}
+
+
+
+
+ ));
+
+ story('Error', () => (
+
+
+
+
+ {t('Error loading repositories')}
+
+
+
+
+ ));
+
+ story('No supported integrations installed', () => (
+
+
+
+
+ {t('add an integration')}
+
+
+
+
+ ));
+
+ story('Integration installed, provider has no accessible repos', () => (
+
+
+
+
+
+
+ {t('Configure your provider to allow Sentry to see your repos.')}
+
+
+
+
+
+ ));
+
+ story('Integration installed, repos visible but none added to Sentry', () => (
+
+
+
+
+
+
+ {tct('[github:Allow access] so Sentry can see your repos.', {
+ github: ,
+ })}
+
+
+
+
+
+ ));
+
+ story('Some repos connected', () => (
+
+
+
+
+
+ } disabled={false}>
+ {t('Add all repos')}
+
+
+
+
+
+ ));
+
+ story('All repos connected', () => (
+
+
+
+
+
+ } disabled>
+ {t('Add all repos')}
+
+
+
+
+
+ ));
+});
diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
new file mode 100644
index 00000000000000..1fbe8a64e5f68e
--- /dev/null
+++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
@@ -0,0 +1,285 @@
+import {Fragment, useMemo, useState} from 'react';
+import {css} from '@emotion/react';
+import {useMutation} from '@tanstack/react-query';
+
+import {Button} from '@sentry/scraps/button';
+import {Flex, Stack} from '@sentry/scraps/layout';
+import {ExternalLink, Link} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
+import {Tooltip} from '@sentry/scraps/tooltip';
+
+import {
+ addErrorMessage,
+ addLoadingMessage,
+ addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
+import {openModal} from 'sentry/actionCreators/modal';
+import {isSupportedAutofixProvider} from 'sentry/components/events/autofix/utils';
+import {LoadingError} from 'sentry/components/loadingError';
+import {RepoProviderIcon} from 'sentry/components/repositories/repoProviderIcon';
+import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink';
+import {useScmIntegrationTreeData} from 'sentry/components/repositories/scmIntegrationTree/useScmIntegrationTreeData';
+import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
+import {IconAdd, IconOpen, IconSettings} from 'sentry/icons';
+import {t, tn} from 'sentry/locale';
+import type {
+ IntegrationRepository,
+ OrganizationIntegration,
+} from 'sentry/types/integrations';
+import {defined} from 'sentry/utils';
+import {getApiUrl} from 'sentry/utils/api/getApiUrl';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
+
+interface Props {
+ canWrite: boolean;
+}
+
+export function SCMOverviewSection({canWrite}: Props) {
+ const organization = useOrganization();
+ const {
+ scmIntegrations,
+ connectedIdentifiers,
+ refetchIntegrations,
+ reposByIntegrationId,
+ isPending,
+ isError,
+ } = useScmIntegrationTreeData();
+
+ const supportedScmIntegrations = scmIntegrations.filter(i =>
+ isSupportedAutofixProvider({id: i.provider.key, name: i.provider.name})
+ );
+
+ const seerRepos = useMemo(() => {
+ return Object.entries(reposByIntegrationId ?? {})
+ .filter(([integrationId, _]) => {
+ return supportedScmIntegrations.some(i => i.id === integrationId);
+ })
+ .flatMap(([_, repos]) => repos);
+ }, [reposByIntegrationId, supportedScmIntegrations]);
+
+ const connectedRepos = useMemo(() => {
+ return seerRepos.filter(repo => connectedIdentifiers.has(repo.identifier));
+ }, [connectedIdentifiers, seerRepos]);
+
+ const unconnectedRepos = useMemo(() => {
+ return supportedScmIntegrations.flatMap(integration => {
+ const repos = reposByIntegrationId[integration.id] ?? [];
+ return repos
+ .filter(repo => !connectedIdentifiers.has(repo.identifier))
+ .map(repo => ({repo, integration}));
+ });
+ }, [supportedScmIntegrations, reposByIntegrationId, connectedIdentifiers]);
+
+ const stat = (
+
+ );
+
+ return (
+
+
+ {isPending ? null : (
+
+
+ {t('Configure')}
+
+
+ )}
+
+ {isPending ? (
+ stat
+ ) : isError ? (
+
+ {stat}
+
+
+
+
+ ) : supportedScmIntegrations.length === 0 ? (
+
+
+
+ {t(
+ 'In order for Seer to work you must make sure at least one integration (Github, Gitlab etc) and at least one repo are connected to Sentry.'
+ )}
+
+
+ ) : (
+
+ {stat}
+ {seerRepos.length === 0 ? (
+
+ ) : (
+ {
+ refetchIntegrations();
+ }}
+ />
+ )}
+
+ )}
+
+ );
+}
+
+function InstallIntegrationButton({onClose}: {onClose: () => void}) {
+ return (
+ }
+ onClick={() => {
+ openModal(
+ deps => ,
+ {
+ modalCss: css`
+ width: 700px;
+ `,
+ onClose,
+ }
+ );
+ }}
+ >
+ {t('Install an Integration')}
+
+ );
+}
+
+function CreateReposButton({
+ seerIntegrations,
+}: {
+ seerIntegrations: OrganizationIntegration[];
+}) {
+ // no repos? link to github
+ const externalLinks = seerIntegrations
+ .map(integration => getProviderConfigUrl(integration))
+ .filter(defined);
+ if (externalLinks.length === 0) {
+ return (
+
+ {t('Configure your provider to allow Sentry to see your repos.')}
+
+ );
+ }
+ return (
+
+
+ {t('Allow Access so Sentry can see your repos.')}
+
+
+ {seerIntegrations.map(integration => {
+ const href = getProviderConfigUrl(integration);
+ if (!href) {
+ return null;
+ }
+ return (
+
+
+ e.stopPropagation()}>
+
+
+ {integration.domainName ?? integration.provider.name}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function ConnectAllReposButton({
+ disabled,
+ unconnectedRepos,
+ onDone,
+}: {
+ disabled: boolean;
+ onDone: () => void;
+ unconnectedRepos: Array<{
+ integration: OrganizationIntegration;
+ repo: IntegrationRepository;
+ }>;
+}) {
+ const organization = useOrganization();
+ const [isBusy, setIsBusy] = useState(false);
+
+ const {mutateAsync} = useMutation({
+ mutationFn: ({
+ repo,
+ integration,
+ }: {
+ integration: OrganizationIntegration;
+ repo: IntegrationRepository;
+ }) =>
+ apiFetch({
+ method: 'POST',
+ url: getApiUrl('/organizations/$organizationIdOrSlug/repos/', {
+ path: {organizationIdOrSlug: organization.slug},
+ }),
+ data: {
+ installation: integration.id,
+ identifier: repo.identifier,
+ provider: `integrations:${integration.provider.key}`,
+ },
+ }),
+ });
+
+ async function handleClick() {
+ setIsBusy(true);
+ addLoadingMessage(t('Connecting repositories\u2026'));
+ try {
+ const results = await Promise.allSettled(
+ unconnectedRepos.map(({repo, integration}) => mutateAsync({repo, integration}))
+ );
+ const failed = results.filter(r => r.status === 'rejected').length;
+ const succeeded = results.filter(r => r.status === 'fulfilled').length;
+ if (failed === 0) {
+ addSuccessMessage(t('All repositories connected'));
+ } else if (succeeded === 0) {
+ addErrorMessage(t('Failed to connect repositories'));
+ } else {
+ addErrorMessage(t('%s repositories connected, %s failed', succeeded, failed));
+ }
+ } finally {
+ setIsBusy(false);
+ onDone();
+ }
+ }
+
+ return (
+
+ }
+ disabled={disabled}
+ busy={isBusy}
+ onClick={handleClick}
+ >
+ {t('Add all repos')}
+
+
+ );
+}
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index 08bf20cbfa54d8..09402d4568694b 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -1,5 +1,5 @@
import {Alert} from '@sentry/scraps/alert';
-import {Flex, Stack} from '@sentry/scraps/layout';
+import {Flex, Grid, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {Form} from 'sentry/components/forms/form';
@@ -11,6 +11,7 @@ import {DEFAULT_CODE_REVIEW_TRIGGERS} from 'sentry/types/integrations';
import type {Organization} from 'sentry/types/organization';
import {useOrganization} from 'sentry/utils/useOrganization';
import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader';
+import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection';
import {SeerSettingsPageContent} from 'getsentry/views/seerAutomation/components/seerSettingsPageContent';
import {SeerSettingsPageWrapper} from 'getsentry/views/seerAutomation/components/seerSettingsPageWrapper';
@@ -20,6 +21,8 @@ export function SeerAutomationSettings() {
const organization = useOrganization();
const canWrite = useCanWriteSettings();
+ const showSeerOverview = organization.features.includes('seer-overview');
+
return (
@@ -41,6 +44,11 @@ export function SeerAutomationSettings() {
)}
/>
+ {showSeerOverview ? (
+
+
+
+ ) : null}
- The component internally calls useScmIntegrationTreeData() to load
- data — the stories below illustrate each visual state by rendering the underlying{' '}
- SeerOverview primitives directly.
+ The component uses useSCMOverviewSection() to derive display state
+ from useScmIntegrationTreeData(), then passes the result to{' '}
+ for rendering. The stories
+ below exercise each visual state by passing controlled props directly to the view.
));
story('Loading', () => (
-
-
-
- {t('Loading…')}
-
-
-
+
));
story('Error', () => (
-
-
-
- {t('Error loading repositories')}
-
-
-
+
));
story('No supported integrations installed', () => (
-
-
-
- {t('add an integration')}
-
-
-
+
));
story('Integration installed, provider has no accessible repos', () => (
-
-
-
-
-
- {t('Configure your provider to allow Sentry to see your repos.')}
-
-
-
-
+
));
story('Integration installed, repos visible but none added to Sentry', () => (
-
-
-
-
-
- {tct('[github:Allow access] so Sentry can see your repos.', {
- github: ,
- })}
-
-
-
-
+
));
story('Some repos connected', () => (
-
-
-
-
- } disabled={false}>
- {t('Add all repos')}
-
-
-
-
+ ({
+ repo,
+ integration: GITHUB_INTEGRATION,
+ }))}
+ />
));
story('All repos connected', () => (
-
-
-
-
- } disabled>
- {t('Add all repos')}
-
-
-
-
+
+
+ ));
+
+ story('Read-only (canWrite: false)', () => (
+
+ ({
+ repo,
+ integration: GITHUB_INTEGRATION,
+ }))}
+ />
));
});
diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
index 371a2f7f49e250..9d57d2186f98e2 100644
--- a/static/app/views/settings/seer/overview/scmOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
@@ -32,12 +32,21 @@ import {fetchMutation} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
-interface Props {
- canWrite: boolean;
+interface SCMOverviewSectionData {
+ connectedRepos: IntegrationRepository[];
+ isError: boolean;
+ isPending: boolean;
+ isReposPending: boolean;
+ refetchIntegrations: () => void;
+ seerRepos: IntegrationRepository[];
+ supportedScmIntegrations: OrganizationIntegration[];
+ unconnectedRepos: Array<{
+ integration: OrganizationIntegration;
+ repo: IntegrationRepository;
+ }>;
}
-export function SCMOverviewSection({canWrite}: Props) {
- const organization = useOrganization();
+function useSCMOverviewSection(): SCMOverviewSectionData {
const {
scmIntegrations,
connectedIdentifiers,
@@ -57,27 +66,60 @@ export function SCMOverviewSection({canWrite}: Props) {
);
const isReposPending = Object.values(reposPendingByIntegrationId).some(Boolean);
+
const seerRepos = useMemo(() => {
return Object.entries(reposByIntegrationId ?? {})
- .filter(([integrationId, _]) => {
- return supportedScmIntegrations.some(i => i.id === integrationId);
- })
+ .filter(([integrationId]) =>
+ supportedScmIntegrations.some(i => i.id === integrationId)
+ )
.flatMap(([_, repos]) => repos);
}, [reposByIntegrationId, supportedScmIntegrations]);
- const connectedRepos = useMemo(() => {
- return seerRepos.filter(repo => connectedIdentifiers.has(repo.identifier));
- }, [connectedIdentifiers, seerRepos]);
+ const connectedRepos = useMemo(
+ () => seerRepos.filter(repo => connectedIdentifiers.has(repo.identifier)),
+ [connectedIdentifiers, seerRepos]
+ );
+
+ const unconnectedRepos = useMemo(
+ () =>
+ supportedScmIntegrations.flatMap(integration => {
+ const repos = reposByIntegrationId[integration.id] ?? [];
+ return repos
+ .filter(repo => !connectedIdentifiers.has(repo.identifier))
+ .map(repo => ({repo, integration}));
+ }),
+ [supportedScmIntegrations, reposByIntegrationId, connectedIdentifiers]
+ );
+
+ return {
+ isPending,
+ isError,
+ isReposPending,
+ supportedScmIntegrations,
+ seerRepos,
+ connectedRepos,
+ unconnectedRepos,
+ refetchIntegrations,
+ };
+}
- const unconnectedRepos = useMemo(() => {
- return supportedScmIntegrations.flatMap(integration => {
- const repos = reposByIntegrationId[integration.id] ?? [];
- return repos
- .filter(repo => !connectedIdentifiers.has(repo.identifier))
- .map(repo => ({repo, integration}));
- });
- }, [supportedScmIntegrations, reposByIntegrationId, connectedIdentifiers]);
+interface SCMOverviewSectionViewProps extends SCMOverviewSectionData {
+ canWrite: boolean;
+ organizationSlug: string;
+}
+export function SCMOverviewSectionView({
+ canWrite,
+ organizationSlug,
+ isPending,
+ isError,
+ isReposPending,
+ supportedScmIntegrations,
+ seerRepos,
+ connectedRepos,
+ unconnectedRepos,
+ refetchIntegrations,
+}: SCMOverviewSectionViewProps) {
const stat = (
{isPending ? null : (
-
+
{t('Configure')}
@@ -136,11 +178,10 @@ export function SCMOverviewSection({canWrite}: Props) {
) : (
{
- refetchIntegrations();
- }}
+ onDone={refetchIntegrations}
/>
)}
@@ -149,6 +190,22 @@ export function SCMOverviewSection({canWrite}: Props) {
);
}
+interface Props {
+ canWrite: boolean;
+}
+
+export function SCMOverviewSection({canWrite}: Props) {
+ const organization = useOrganization();
+ const data = useSCMOverviewSection();
+ return (
+
+ );
+}
+
function InstallIntegrationButton({onClose}: {onClose: () => void}) {
return (
getProviderConfigUrl(integration))
.filter(defined);
@@ -219,18 +275,19 @@ function CreateReposButton({
}
function ConnectAllReposButton({
+ organizationSlug,
disabled,
unconnectedRepos,
onDone,
}: {
disabled: boolean;
onDone: () => void;
+ organizationSlug: string;
unconnectedRepos: Array<{
integration: OrganizationIntegration;
repo: IntegrationRepository;
}>;
}) {
- const organization = useOrganization();
const [isBusy, setIsBusy] = useState(false);
const {mutateAsync} = useMutation({
@@ -244,7 +301,7 @@ function ConnectAllReposButton({
fetchMutation({
method: 'POST',
url: getApiUrl('/organizations/$organizationIdOrSlug/repos/', {
- path: {organizationIdOrSlug: organization.slug},
+ path: {organizationIdOrSlug: organizationSlug},
}),
data: {
installation: integration.id,
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index ae3cfeaeebcd3e..516c7658c19001 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -3,7 +3,7 @@ import {z} from 'zod';
import {Alert} from '@sentry/scraps/alert';
import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
-import {Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {updateOrganization} from 'sentry/actionCreators/organizations';
@@ -15,6 +15,7 @@ import type {Organization} from 'sentry/types/organization';
import {fetchMutation} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader';
+import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection';
import {SeerSettingsPageContent} from 'getsentry/views/seerAutomation/components/seerSettingsPageContent';
@@ -79,9 +80,9 @@ export function SeerAutomationSettings() {
/>
{showSeerOverview ? (
-
+
-
+
) : null}
Date: Fri, 20 Mar 2026 13:48:21 -0700
Subject: [PATCH 05/29] squash
---
.../seer/overview/seerOverview.stories.tsx | 175 ++++++++
.../settings/seer/overview/seerOverview.tsx | 387 ++++++++++++++++++
.../overview/useSeerOverviewData.spec.tsx | 269 ++++++++++++
.../seer/overview/useSeerOverviewData.tsx | 106 +++++
.../organizationIntegrationsQueryOptions.ts | 22 +
5 files changed, 959 insertions(+)
create mode 100644 static/app/views/settings/seer/overview/seerOverview.stories.tsx
create mode 100644 static/app/views/settings/seer/overview/seerOverview.tsx
create mode 100644 static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx
create mode 100644 static/app/views/settings/seer/overview/useSeerOverviewData.tsx
create mode 100644 static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts
diff --git a/static/app/views/settings/seer/overview/seerOverview.stories.tsx b/static/app/views/settings/seer/overview/seerOverview.stories.tsx
new file mode 100644
index 00000000000000..dc966ed315bffd
--- /dev/null
+++ b/static/app/views/settings/seer/overview/seerOverview.stories.tsx
@@ -0,0 +1,175 @@
+import {Grid} from '@sentry/scraps/layout';
+
+import * as Storybook from 'sentry/stories';
+import type {OrganizationIntegration} from 'sentry/types/integrations';
+import {
+ AutofixOverviewSection,
+ CodeReviewOverviewSection,
+ SCMOverviewSection,
+} from 'sentry/views/settings/seer/overview/seerOverview';
+import type {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
+
+function OrganizationIntegrationsFixture(
+ params: Partial = {}
+): OrganizationIntegration {
+ return {
+ accountType: '',
+ gracePeriodEnd: '',
+ organizationIntegrationStatus: 'active',
+ domainName: 'github.com',
+ icon: 'https://secure.gravatar.com/avatar/8b4cb68e40b74c90427d8262256bd1c8',
+ id: '5',
+ name: 'NisanthanNanthakumar',
+ provider: {
+ aspects: {},
+ canAdd: true,
+ canDisable: false,
+ features: ['commits', 'issue-basic'],
+ key: 'github',
+ name: 'Github',
+ slug: 'github',
+ },
+ status: 'active',
+ configData: null,
+ configOrganization: [],
+ externalId: 'ext-integration-1',
+ organizationId: '1',
+ ...params,
+ };
+}
+
+const seerIntegrationsFixture = [
+ OrganizationIntegrationsFixture({id: '1', name: 'Integration A'}),
+ OrganizationIntegrationsFixture({id: '2', name: 'Integration B'}),
+];
+
+const baseStats: ReturnType['stats'] = {
+ integrationCount: 2,
+ scmIntegrationCount: 2,
+ seerIntegrations: seerIntegrationsFixture,
+ seerIntegrationCount: 2,
+ totalRepoCount: 10,
+ seerRepoCount: 10, // equal to totalRepoCount: no "Add all repos" button
+ reposWithSettingsCount: 10,
+ projectsWithReposCount: 6, // equal to totalProjects: no "Handoff all to" CompactSelect
+ projectsWithAutomationCount: 6,
+ projectsWithCreatePrCount: 6,
+ totalProjects: 6,
+ reposWithCodeReviewCount: 10, // equal to seerRepoCount
+};
+
+function SeerOverview({
+ stats,
+ isLoading,
+}: {
+ isLoading: boolean;
+ stats: ReturnType['stats'];
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+export default Storybook.story('SeerOverview', story => {
+ story('No alerts (healthy state)', () => (
+
+ ));
+
+ story('Loading state', () => );
+
+ // SCM stories
+
+ story('SCM: No SCM integrations installed', () => (
+
+ ));
+
+ story('SCM: Integrations installed but no repos connected', () => (
+
+ ));
+
+ story('SCM: Some repos not yet added to Seer', () => (
+
+ ));
+
+ // Autofix stories
+
+ story('Autofix: No projects have repos linked', () => (
+
+ ));
+
+ story('Autofix: Some projects with repos (partial)', () => (
+
+ ));
+
+ // Code Review stories
+
+ story('Code Review: No repos have code review enabled', () => (
+ 0 → ButtonBar visible, shows 0/10
+ }}
+ isLoading={false}
+ />
+ ));
+});
diff --git a/static/app/views/settings/seer/overview/seerOverview.tsx b/static/app/views/settings/seer/overview/seerOverview.tsx
new file mode 100644
index 00000000000000..df3a2cfd0ff17f
--- /dev/null
+++ b/static/app/views/settings/seer/overview/seerOverview.tsx
@@ -0,0 +1,387 @@
+import {Fragment, type ReactNode} from 'react';
+import {css} from '@emotion/react';
+
+import {Button, ButtonBar} from '@sentry/scraps/button';
+import {CompactSelect} from '@sentry/scraps/compactSelect';
+import {Flex, Grid} from '@sentry/scraps/layout';
+import {ExternalLink, Link} from '@sentry/scraps/link';
+import {Heading, Text} from '@sentry/scraps/text';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
+import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink';
+import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
+import {IconAdd, IconCheckmark, IconClose, IconSettings} from 'sentry/icons';
+import {t, tct, tn} from 'sentry/locale';
+import {defined} from 'sentry/utils';
+import {useQuery} from 'sentry/utils/queryClient';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
+
+import {useAgentOptions} from 'getsentry/views/seerAutomation/components/seerAgentHooks';
+
+function formatStatValue(value: number, outOf: number | undefined, isLoading: boolean) {
+ if (isLoading) {
+ return '\u2014';
+ }
+ return outOf === undefined ? value : `${value}\u2009/\u2009${outOf}`;
+}
+
+function Section({children}: {children?: ReactNode}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SectionHeader({children, title}: {title: string; children?: ReactNode}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function StatRow({
+ value,
+ label,
+ children,
+}: {
+ label: string;
+ value: string | number;
+ children?: ReactNode;
+}) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+interface Props {
+ isLoading: boolean;
+ stats: ReturnType['stats'];
+}
+
+export function SCMOverviewSection({stats, isLoading}: Props) {
+ const organization = useOrganization();
+
+ return (
+
+
+ {!isLoading && stats.seerIntegrationCount > 0 && stats.seerRepoCount > 0 ? (
+
+
+ {t('Configure')}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SCMProviderWidgets({stats, isLoading}: Props) {
+ if (isLoading) {
+ return null;
+ }
+ if (stats.seerIntegrationCount === 0) {
+ return (
+ }
+ onClick={() => {
+ openModal(
+ deps => ,
+ {
+ modalCss: css`
+ width: 700px;
+ `,
+ onClose: () => {
+ // TODO: invalidate queries to refresh the page
+ // queryClient.invalidateQueries({queryKey: queryOptions.queryKey});
+ },
+ }
+ );
+ }}
+ >
+ {t('Install Integration')}
+
+ );
+ }
+ return null;
+}
+
+function SCMReposWidgets({stats, isLoading}: Props) {
+ if (isLoading || stats.seerIntegrationCount === 0) {
+ return null;
+ }
+ if (stats.totalRepoCount === 0) {
+ // no repos? link to github
+ const externalLinks = stats.seerIntegrations
+ .map(integration => getProviderConfigUrl(integration))
+ .filter(defined);
+ if (externalLinks.length === 0) {
+ return (
+
+ {t('Configure your provider to allow Sentry to see your repos.')}
+
+ );
+ }
+ return (
+
+ {tct('[github:Allow access] to Sentry can see your repos.', {
+ github: ,
+ })}
+
+ );
+ }
+ if (stats.seerRepoCount !== stats.totalRepoCount) {
+ return (
+
+ }
+ onClick={() => {
+ // TODO
+ }}
+ >
+ {t('Add all repos')}
+
+ {
+ e.preventDefault();
+ openModal(
+ deps => ,
+ {
+ modalCss: css`
+ width: 700px;
+ `,
+ onClose: () => {
+ // TODO: invalidate queries to refresh the page
+ // queryClient.invalidateQueries({queryKey: queryOptions.queryKey});
+ },
+ }
+ );
+ }}
+ >
+ {t('Fine tune')}
+
+
+ );
+ }
+ return null;
+}
+
+export function AutofixOverviewSection({stats, isLoading}: Props) {
+ const organization = useOrganization();
+
+ const {data: integrations} = useQuery({
+ ...organizationIntegrationsCodingAgents(organization),
+ select: data => data.json.integrations ?? [],
+ });
+ const options = useAgentOptions({integrations: integrations ?? []});
+
+ return (
+
+
+ {!isLoading && (stats.projectsWithReposCount ?? 0) > 0 ? (
+
+
+ {t('Configure')}
+
+
+ ) : null}
+
+
+ {null}
+
+
+
+ {!isLoading &&
+ stats.projectsWithReposCount &&
+ stats.projectsWithReposCount !== stats.totalProjects ? (
+
+
+ {t('Handoff all to:')}
+
+ ({
+ value:
+ typeof option.value === 'string'
+ ? option.value
+ : (option.value.id ?? ''),
+ label: option.label,
+ }))}
+ value="1"
+ onChange={() => {
+ // mutateSelectedAgent(option.value, {
+ }}
+ />
+
+ ) : null}
+
+
+
+ {!isLoading && stats.projectsWithReposCount ? (
+
+
+ {t('Update all projects to:')}
+
+
+ }
+ disabled={stats.projectsWithReposCount === stats.totalProjects}
+ onClick={() => {
+ // TODO
+ }}
+ >
+ {t('Enabled')}
+
+ }
+ disabled={stats.projectsWithReposCount === 0}
+ onClick={() => {
+ // TODO
+ }}
+ >
+ {t('Disabled')}
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export function CodeReviewOverviewSection({stats, isLoading}: Props) {
+ const organization = useOrganization();
+
+ return (
+
+
+ {!isLoading && stats.seerRepoCount > 0 ? (
+
+
+ {t('Configure')}
+
+
+ ) : null}
+
+
+ {!isLoading && stats.seerRepoCount ? (
+
+
+ {t('Update all repos to:')}
+
+
+ }
+ disabled={stats.projectsWithReposCount === stats.totalProjects}
+ onClick={() => {
+ // TODO
+ }}
+ >
+ {t('Enabled')}
+
+ }
+ disabled={stats.projectsWithReposCount === 0}
+ onClick={() => {
+ // TODO
+ }}
+ >
+ {t('Disabled')}
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx b/static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx
new file mode 100644
index 00000000000000..abfabb69dcd9d2
--- /dev/null
+++ b/static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx
@@ -0,0 +1,269 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {RepositoryFixture} from 'sentry-fixture/repository';
+
+import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import type {
+ OrganizationIntegration,
+ RepositoryWithSettings,
+} from 'sentry/types/integrations';
+import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
+
+function RepoWithSettingsFixture(
+ params: Partial = {}
+): RepositoryWithSettings {
+ return {
+ ...RepositoryFixture(),
+ settings: null,
+ ...params,
+ };
+}
+
+function IntegrationFixture(
+ params: Partial & {features?: string[]} = {}
+): OrganizationIntegration {
+ const {features = ['commits'], ...rest} = params;
+ return {
+ id: 'integration-1',
+ name: 'Test Integration',
+ domainName: 'github.com/test',
+ icon: null,
+ accountType: null,
+ gracePeriodEnd: null,
+ organizationIntegrationStatus: 'active',
+ status: 'active',
+ externalId: 'ext-integration-1',
+ organizationId: '1',
+ configData: null,
+ configOrganization: [],
+ provider: {
+ key: 'github',
+ slug: 'github',
+ name: 'GitHub',
+ canAdd: true,
+ canDisable: false,
+ features,
+ aspects: {},
+ },
+ ...rest,
+ };
+}
+
+describe('useSeerOverviewData', () => {
+ const organization = OrganizationFixture({slug: 'org-slug'});
+
+ afterEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ function setupMocks({
+ repos = [],
+ autofixSettings = [],
+ integrations = [],
+ }: {
+ autofixSettings?: Array<{
+ autofixAutomationTuning: string | null;
+ projectId: string;
+ reposCount: number;
+ }>;
+ integrations?: OrganizationIntegration[];
+ repos?: RepositoryWithSettings[];
+ } = {}) {
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/integrations/',
+ method: 'GET',
+ body: integrations,
+ });
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ method: 'GET',
+ body: repos,
+ });
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/autofix/automation-settings/',
+ method: 'GET',
+ body: autofixSettings,
+ });
+ }
+
+ it('returns zeroed stats when there are no repos or projects', async () => {
+ setupMocks();
+
+ const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.stats).toEqual({
+ integrationCount: 0,
+ totalRepoCount: 0,
+ seerRepoCount: 0,
+ reposWithSettingsCount: 0,
+ projectsWithReposCount: 0,
+ projectsWithAutomationCount: 0,
+ totalProjects: 0,
+ reposWithCodeReviewCount: 0,
+ });
+ });
+
+ it('counts repos and integrations with commits feature', async () => {
+ setupMocks({
+ repos: [
+ RepoWithSettingsFixture({
+ id: '1',
+ externalId: 'ext-1',
+ integrationId: 'integration-a',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ }),
+ RepoWithSettingsFixture({
+ id: '2',
+ externalId: 'ext-2',
+ integrationId: 'integration-a',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ }),
+ RepoWithSettingsFixture({
+ id: '3',
+ externalId: 'ext-3',
+ integrationId: 'integration-b',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ }),
+ ],
+ integrations: [
+ IntegrationFixture({id: 'integration-a', features: ['commits', 'issue-basic']}),
+ IntegrationFixture({id: 'integration-b', features: ['commits']}),
+ IntegrationFixture({id: 'integration-c', features: ['issue-basic']}), // no commits
+ ],
+ });
+
+ const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.stats.totalRepoCount).toBe(3);
+ expect(result.current.stats.seerRepoCount).toBe(3);
+ expect(result.current.stats.integrationCount).toBe(2); // only integrations with 'commits'
+ });
+
+ it('only counts repos with supported providers toward seerRepoCount', async () => {
+ setupMocks({
+ repos: [
+ RepoWithSettingsFixture({
+ id: '1',
+ externalId: 'ext-1',
+ integrationId: 'integration-a',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ }),
+ RepoWithSettingsFixture({
+ id: '2',
+ externalId: 'ext-2',
+ integrationId: 'integration-b',
+ provider: {id: 'integrations:bitbucket', name: 'Bitbucket'}, // unsupported
+ }),
+ ],
+ });
+
+ const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.stats.totalRepoCount).toBe(2);
+ expect(result.current.stats.seerRepoCount).toBe(1);
+ });
+
+ it('counts repos with code review enabled', async () => {
+ setupMocks({
+ repos: [
+ RepoWithSettingsFixture({
+ id: '1',
+ externalId: 'ext-1',
+ integrationId: 'integration-a',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ settings: {enabledCodeReview: true, codeReviewTriggers: []},
+ }),
+ RepoWithSettingsFixture({
+ id: '2',
+ externalId: 'ext-2',
+ integrationId: 'integration-a',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ settings: {enabledCodeReview: false, codeReviewTriggers: []},
+ }),
+ RepoWithSettingsFixture({
+ id: '3',
+ externalId: 'ext-3',
+ integrationId: 'integration-a',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ settings: null, // no settings
+ }),
+ ],
+ });
+
+ const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.stats.reposWithCodeReviewCount).toBe(1);
+ });
+
+ it('counts projects with repos and with automation enabled', async () => {
+ setupMocks({
+ autofixSettings: [
+ {projectId: '1', reposCount: 2, autofixAutomationTuning: 'medium'},
+ {projectId: '2', reposCount: 1, autofixAutomationTuning: 'off'},
+ {projectId: '3', reposCount: 0, autofixAutomationTuning: 'off'},
+ {projectId: '4', reposCount: 0, autofixAutomationTuning: 'medium'},
+ ],
+ });
+
+ const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.stats.totalProjects).toBe(4);
+ expect(result.current.stats.projectsWithReposCount).toBe(2);
+ expect(result.current.stats.projectsWithAutomationCount).toBe(2);
+ });
+
+ it('counts all non-off automation tuning values as enabled', async () => {
+ setupMocks({
+ autofixSettings: [
+ {projectId: '1', reposCount: 1, autofixAutomationTuning: 'medium'},
+ {projectId: '2', reposCount: 1, autofixAutomationTuning: 'high'},
+ {projectId: '3', reposCount: 1, autofixAutomationTuning: 'always'},
+ {projectId: '4', reposCount: 0, autofixAutomationTuning: 'off'},
+ {projectId: '5', reposCount: 0, autofixAutomationTuning: null},
+ ],
+ });
+
+ const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ // only 'off' means disabled; null (deprecated) is also treated as enabled
+ expect(result.current.stats.projectsWithAutomationCount).toBe(4);
+ });
+
+ it('deduplicates repos by externalId', async () => {
+ setupMocks({
+ repos: [
+ RepoWithSettingsFixture({
+ id: '1',
+ externalId: 'same-external-id',
+ integrationId: 'integration-a',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ }),
+ RepoWithSettingsFixture({
+ id: '2',
+ externalId: 'same-external-id', // duplicate
+ integrationId: 'integration-b',
+ provider: {id: 'integrations:github', name: 'GitHub'},
+ }),
+ ],
+ });
+
+ const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.stats.totalRepoCount).toBe(1);
+ expect(result.current.stats.seerRepoCount).toBe(1);
+ });
+});
diff --git a/static/app/views/settings/seer/overview/useSeerOverviewData.tsx b/static/app/views/settings/seer/overview/useSeerOverviewData.tsx
new file mode 100644
index 00000000000000..e0014d4c6aefb1
--- /dev/null
+++ b/static/app/views/settings/seer/overview/useSeerOverviewData.tsx
@@ -0,0 +1,106 @@
+import {useMemo} from 'react';
+import uniqBy from 'lodash/uniqBy';
+
+import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
+import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories';
+import {isSupportedAutofixProvider} from 'sentry/components/events/autofix/utils';
+import {useFetchAllPages} from 'sentry/utils/api/apiFetch';
+import {useInfiniteQuery, useQuery} from 'sentry/utils/queryClient';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {organizationIntegrationsQueryOptions} from 'sentry/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions';
+
+export function useSeerOverviewData() {
+ const organization = useOrganization();
+
+ // SCM Data
+ const {data: integrationData, isPending: isIntegrationsPending} = useQuery({
+ ...organizationIntegrationsQueryOptions({organization}),
+ select: data => {
+ const allIntegrations = data.json.filter(i => i !== null);
+ const scmIntegrations = allIntegrations.filter(integration =>
+ integration.provider.features.includes('commits')
+ );
+ const seerIntegrations = scmIntegrations.filter(integration =>
+ isSupportedAutofixProvider({
+ id: integration.provider.key,
+ name: integration.provider.name,
+ })
+ );
+ return {
+ integrations: allIntegrations,
+ scmIntegrations,
+ seerIntegrations,
+ };
+ },
+ });
+
+ // Repos Data
+ const repositoriesResult = useInfiniteQuery({
+ ...organizationRepositoriesInfiniteOptions({
+ organization,
+ query: {per_page: 100},
+ }),
+ select: ({pages}) => {
+ const allRepos = uniqBy(
+ pages.flatMap(page => page.json),
+ 'externalId'
+ ).filter(repository => repository.externalId);
+ const seerRepos = allRepos.filter(r => isSupportedAutofixProvider(r.provider));
+ return {
+ allRepos,
+ seerRepos,
+ reposWithSettings: seerRepos.filter(r => r.settings !== null),
+ reposWithCodeReview: seerRepos.filter(r => r.settings?.enabledCodeReview),
+ };
+ },
+ });
+ useFetchAllPages({result: repositoriesResult});
+ const {data: repositoryData, isPending: isReposPending} = repositoriesResult;
+
+ // Autofix Data
+ const autofixSettingsResult = useInfiniteQuery({
+ ...bulkAutofixAutomationSettingsInfiniteOptions({organization}),
+ select: ({pages}) => {
+ const autofixItems = pages.flatMap(page => page.json).filter(s => s !== null);
+ return {
+ autofixItems,
+ projectsWithRepos: autofixItems.filter(settings => settings.reposCount > 0),
+ projectsWithAutomation: autofixItems.filter(
+ settings => settings.autofixAutomationTuning !== 'off'
+ ),
+ projectsWithCreatePr: autofixItems.filter(
+ settings => settings.automationHandoff?.auto_create_pr
+ ),
+ };
+ },
+ });
+ useFetchAllPages({result: autofixSettingsResult});
+ const {data: autofixData, isPending: isAutofixPending} = autofixSettingsResult;
+
+ const stats = useMemo(() => {
+ return {
+ // SCM Stats
+ integrationCount: integrationData?.integrations.length ?? 0,
+ scmIntegrationCount: integrationData?.scmIntegrations.length ?? 0,
+ seerIntegrations: integrationData?.seerIntegrations ?? [],
+ seerIntegrationCount: integrationData?.seerIntegrations.length ?? 0,
+
+ // Autofix Stats
+ totalProjects: autofixData?.autofixItems.length ?? 0,
+ projectsWithReposCount: autofixData?.projectsWithRepos.length ?? 0,
+ projectsWithAutomationCount: autofixData?.projectsWithAutomation.length ?? 0,
+ projectsWithCreatePrCount: autofixData?.projectsWithCreatePr.length ?? 0,
+
+ // Repos Stats
+ totalRepoCount: repositoryData?.allRepos.length ?? 0,
+ seerRepoCount: repositoryData?.seerRepos.length ?? 0,
+ reposWithSettingsCount: repositoryData?.reposWithSettings.length ?? 0,
+ reposWithCodeReviewCount: repositoryData?.reposWithCodeReview.length ?? 0,
+ };
+ }, [integrationData, autofixData, repositoryData]);
+
+ return {
+ stats,
+ isLoading: isIntegrationsPending || isReposPending || isAutofixPending,
+ };
+}
diff --git a/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts b/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts
new file mode 100644
index 00000000000000..862aa7edb50c36
--- /dev/null
+++ b/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts
@@ -0,0 +1,22 @@
+import type {OrganizationIntegration} from 'sentry/types/integrations';
+import type {Organization} from 'sentry/types/organization';
+import {apiOptions} from 'sentry/utils/api/apiOptions';
+
+export function organizationIntegrationsQueryOptions({
+ organization,
+ staleTime = 60_000,
+ includeConfig = 0,
+}: {
+ organization: Organization;
+ includeConfig?: number;
+ staleTime?: number;
+}) {
+ return apiOptions.as()(
+ '/organizations/$organizationIdOrSlug/integrations/',
+ {
+ path: {organizationIdOrSlug: organization.slug},
+ query: {includeConfig},
+ staleTime,
+ }
+ );
+}
From 0b7c98044a7968d688f766276ece4a5a98290963 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Sun, 22 Mar 2026 09:35:05 -0700
Subject: [PATCH 06/29] split it up[
---
static/app/types/organization.tsx | 2 +
.../seer/overview/autofixOverviewSection.tsx | 208 ++++++++++
.../overview/codeReviewOverviewSection.tsx | 158 +++++++
.../seer/overview/seerOverview.stories.tsx | 33 +-
.../settings/seer/overview/seerOverview.tsx | 387 ------------------
.../gsApp/views/seerAutomation/settings.tsx | 6 +
6 files changed, 389 insertions(+), 405 deletions(-)
create mode 100644 static/app/views/settings/seer/overview/autofixOverviewSection.tsx
create mode 100644 static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
delete mode 100644 static/app/views/settings/seer/overview/seerOverview.tsx
diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx
index dbe1f5e85f24af..e38e784599c112 100644
--- a/static/app/types/organization.tsx
+++ b/static/app/types/organization.tsx
@@ -65,6 +65,8 @@ export interface Organization extends OrganizationSummary {
dataScrubberDefaults: boolean;
debugFilesRole: string;
defaultCodeReviewTriggers: CodeReviewTrigger[];
+ defaultCodingAgent: string | null;
+ defaultCodingAgentIntegrationId: number | null;
defaultRole: string;
enhancedPrivacy: boolean;
eventsMemberAdmin: boolean;
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
new file mode 100644
index 00000000000000..b7d93404708dd1
--- /dev/null
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -0,0 +1,208 @@
+import {mutationOptions} from '@tanstack/react-query';
+import {z} from 'zod';
+
+import {Button} from '@sentry/scraps/button';
+import {AutoSaveForm} from '@sentry/scraps/form';
+import {Flex} from '@sentry/scraps/layout';
+import {Link} from '@sentry/scraps/link';
+
+import {updateOrganization} from 'sentry/actionCreators/organizations';
+import {hasEveryAccess} from 'sentry/components/acl/access';
+import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
+import {IconSettings} from 'sentry/icons';
+import {t, tn} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {fetchMutation, useQuery} from 'sentry/utils/queryClient';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
+import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
+import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks';
+
+interface Props {
+ isLoading: boolean;
+ stats: ReturnType['stats'];
+}
+
+export function AutofixOverviewSection({stats, isLoading}: Props) {
+ const organization = useOrganization();
+ const canWrite = hasEveryAccess(['org:write'], {organization});
+
+ const schema = z.object({
+ defaultCodingAgent: z.string().nullable(),
+ defaultCodingAgentIntegrationId: z.number().nullable(),
+ defaultAutofixAutomationTuning: z.enum(['off', 'medium']),
+ });
+
+ const {data: integrations} = useQuery({
+ ...organizationIntegrationsCodingAgents(organization),
+ select: data => data.json.integrations ?? [],
+ });
+ const options = useAgentOptions({integrations: integrations ?? []});
+
+ const orgMutationOpts = mutationOptions({
+ mutationFn: (data: Partial) =>
+ fetchMutation({
+ method: 'PUT',
+ url: `/organizations/${organization.slug}/`,
+ data,
+ }),
+ onSuccess: updateOrganization,
+ });
+ const autofixTuningMutationOpts = mutationOptions({
+ mutationFn: (data: {defaultAutofixAutomationTuning: boolean}) =>
+ fetchMutation({
+ method: 'PUT',
+ url: `/organizations/${organization.slug}/`,
+ data: {
+ // All values other than 'off' are converted to 'medium'
+ defaultAutofixAutomationTuning: data.defaultAutofixAutomationTuning
+ ? 'medium'
+ : 'off',
+ },
+ }),
+ onSuccess: updateOrganization,
+ });
+
+ return (
+
+
+ {isLoading ? null : (
+
+
+ {t('Configure')}
+
+
+ )}
+
+
+
+
+
+ {
+ // TODO
+ }}
+ >
+ {t('Connect Projects to Repos')}
+
+
+
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {
+ // TODO
+ }}
+ >
+ {tn('Apply to the project', 'Apply to all %s projects', stats.totalProjects)}
+
+
+ )}
+
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {
+ // TODO
+ }}
+ >
+ {organization.defaultAutofixAutomationTuning === 'off'
+ ? tn(
+ 'Disable for the project',
+ 'Disable for all %s projects',
+ stats.totalProjects
+ )
+ : tn(
+ 'Enable for the project',
+ 'Enable for all %s projects',
+ stats.totalProjects
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
new file mode 100644
index 00000000000000..137a7a1725f030
--- /dev/null
+++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
@@ -0,0 +1,158 @@
+import {mutationOptions} from '@tanstack/react-query';
+import {z} from 'zod';
+
+import {Button} from '@sentry/scraps/button';
+import {AutoSaveForm} from '@sentry/scraps/form';
+import {Flex} from '@sentry/scraps/layout';
+import {Link} from '@sentry/scraps/link';
+
+import {updateOrganization} from 'sentry/actionCreators/organizations';
+import {hasEveryAccess} from 'sentry/components/acl/access';
+import {IconSettings} from 'sentry/icons';
+import {t, tct, tn} from 'sentry/locale';
+import {DEFAULT_CODE_REVIEW_TRIGGERS} from 'sentry/types/integrations';
+import type {Organization} from 'sentry/types/organization';
+import {fetchMutation} from 'sentry/utils/queryClient';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
+import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
+
+interface Props {
+ isLoading: boolean;
+ stats: ReturnType['stats'];
+}
+
+export function CodeReviewOverviewSection({stats, isLoading}: Props) {
+ const organization = useOrganization();
+ const canWrite = hasEveryAccess(['org:write'], {organization});
+
+ const schema = z.object({
+ autoEnableCodeReview: z.boolean(),
+ defaultCodeReviewTriggers: z.array(z.enum(['on_new_commit', 'on_ready_for_review'])),
+ });
+
+ const orgMutationOpts = mutationOptions({
+ mutationFn: (data: Partial) =>
+ fetchMutation({
+ method: 'PUT',
+ url: `/organizations/${organization.slug}/`,
+ data,
+ }),
+ onSuccess: updateOrganization,
+ });
+
+ return (
+
+
+ {isLoading ? null : (
+
+
+ {t('Configure')}
+
+
+ )}
+
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {
+ // TODO
+ }}
+ >
+ {organization.autoEnableCodeReview
+ ? tn(
+ 'Disable for the repo',
+ 'Disable for all %s repos',
+ stats.seerRepoCount
+ )
+ : tn('Enable for the repo', 'Enable for all %s repos', stats.seerRepoCount)}
+
+
+ )}
+
+
+
+
+ {field => (
+ }
+ )}
+ >
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {
+ // TODO
+ }}
+ >
+ {tn('Set for the repo', 'Set for all %s repos', stats.seerRepoCount)}
+
+
+ )}
+
+ );
+}
diff --git a/static/app/views/settings/seer/overview/seerOverview.stories.tsx b/static/app/views/settings/seer/overview/seerOverview.stories.tsx
index dc966ed315bffd..1a3b685526a9de 100644
--- a/static/app/views/settings/seer/overview/seerOverview.stories.tsx
+++ b/static/app/views/settings/seer/overview/seerOverview.stories.tsx
@@ -1,12 +1,9 @@
-import {Grid} from '@sentry/scraps/layout';
-
import * as Storybook from 'sentry/stories';
import type {OrganizationIntegration} from 'sentry/types/integrations';
-import {
- AutofixOverviewSection,
- CodeReviewOverviewSection,
- SCMOverviewSection,
-} from 'sentry/views/settings/seer/overview/seerOverview';
+import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
+import {CodeReviewOverviewSection} from 'sentry/views/settings/seer/overview/codeReviewOverviewSection';
+import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
+import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection';
import type {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
function OrganizationIntegrationsFixture(
@@ -58,7 +55,7 @@ const baseStats: ReturnType['stats'] = {
reposWithCodeReviewCount: 10, // equal to seerRepoCount
};
-function SeerOverview({
+function TestableOverview({
stats,
isLoading,
}: {
@@ -66,25 +63,25 @@ function SeerOverview({
stats: ReturnType['stats'];
}) {
return (
-
+
-
+
);
}
export default Storybook.story('SeerOverview', story => {
story('No alerts (healthy state)', () => (
-
+
));
- story('Loading state', () => );
+ story('Loading state', () => );
// SCM stories
story('SCM: No SCM integrations installed', () => (
- {
));
story('SCM: Integrations installed but no repos connected', () => (
- {
));
story('SCM: Some repos not yet added to Seer', () => (
- {
// Autofix stories
story('Autofix: No projects have repos linked', () => (
- {
));
story('Autofix: Some projects with repos (partial)', () => (
- {
// Code Review stories
story('Code Review: No repos have code review enabled', () => (
- 0 → ButtonBar visible, shows 0/10
diff --git a/static/app/views/settings/seer/overview/seerOverview.tsx b/static/app/views/settings/seer/overview/seerOverview.tsx
deleted file mode 100644
index df3a2cfd0ff17f..00000000000000
--- a/static/app/views/settings/seer/overview/seerOverview.tsx
+++ /dev/null
@@ -1,387 +0,0 @@
-import {Fragment, type ReactNode} from 'react';
-import {css} from '@emotion/react';
-
-import {Button, ButtonBar} from '@sentry/scraps/button';
-import {CompactSelect} from '@sentry/scraps/compactSelect';
-import {Flex, Grid} from '@sentry/scraps/layout';
-import {ExternalLink, Link} from '@sentry/scraps/link';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {openModal} from 'sentry/actionCreators/modal';
-import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
-import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink';
-import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
-import {IconAdd, IconCheckmark, IconClose, IconSettings} from 'sentry/icons';
-import {t, tct, tn} from 'sentry/locale';
-import {defined} from 'sentry/utils';
-import {useQuery} from 'sentry/utils/queryClient';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
-
-import {useAgentOptions} from 'getsentry/views/seerAutomation/components/seerAgentHooks';
-
-function formatStatValue(value: number, outOf: number | undefined, isLoading: boolean) {
- if (isLoading) {
- return '\u2014';
- }
- return outOf === undefined ? value : `${value}\u2009/\u2009${outOf}`;
-}
-
-function Section({children}: {children?: ReactNode}) {
- return (
-
- {children}
-
- );
-}
-
-function SectionHeader({children, title}: {title: string; children?: ReactNode}) {
- return (
-
-
- {title}
-
- {children}
-
- );
-}
-
-function StatRow({
- value,
- label,
- children,
-}: {
- label: string;
- value: string | number;
- children?: ReactNode;
-}) {
- return (
-
-
- {value}
-
-
- {label}
-
-
- {children}
-
-
- );
-}
-
-interface Props {
- isLoading: boolean;
- stats: ReturnType['stats'];
-}
-
-export function SCMOverviewSection({stats, isLoading}: Props) {
- const organization = useOrganization();
-
- return (
-
-
- {!isLoading && stats.seerIntegrationCount > 0 && stats.seerRepoCount > 0 ? (
-
-
- {t('Configure')}
-
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function SCMProviderWidgets({stats, isLoading}: Props) {
- if (isLoading) {
- return null;
- }
- if (stats.seerIntegrationCount === 0) {
- return (
- }
- onClick={() => {
- openModal(
- deps => ,
- {
- modalCss: css`
- width: 700px;
- `,
- onClose: () => {
- // TODO: invalidate queries to refresh the page
- // queryClient.invalidateQueries({queryKey: queryOptions.queryKey});
- },
- }
- );
- }}
- >
- {t('Install Integration')}
-
- );
- }
- return null;
-}
-
-function SCMReposWidgets({stats, isLoading}: Props) {
- if (isLoading || stats.seerIntegrationCount === 0) {
- return null;
- }
- if (stats.totalRepoCount === 0) {
- // no repos? link to github
- const externalLinks = stats.seerIntegrations
- .map(integration => getProviderConfigUrl(integration))
- .filter(defined);
- if (externalLinks.length === 0) {
- return (
-
- {t('Configure your provider to allow Sentry to see your repos.')}
-
- );
- }
- return (
-
- {tct('[github:Allow access] to Sentry can see your repos.', {
- github: ,
- })}
-
- );
- }
- if (stats.seerRepoCount !== stats.totalRepoCount) {
- return (
-
- }
- onClick={() => {
- // TODO
- }}
- >
- {t('Add all repos')}
-
- {
- e.preventDefault();
- openModal(
- deps => ,
- {
- modalCss: css`
- width: 700px;
- `,
- onClose: () => {
- // TODO: invalidate queries to refresh the page
- // queryClient.invalidateQueries({queryKey: queryOptions.queryKey});
- },
- }
- );
- }}
- >
- {t('Fine tune')}
-
-
- );
- }
- return null;
-}
-
-export function AutofixOverviewSection({stats, isLoading}: Props) {
- const organization = useOrganization();
-
- const {data: integrations} = useQuery({
- ...organizationIntegrationsCodingAgents(organization),
- select: data => data.json.integrations ?? [],
- });
- const options = useAgentOptions({integrations: integrations ?? []});
-
- return (
-
-
- {!isLoading && (stats.projectsWithReposCount ?? 0) > 0 ? (
-
-
- {t('Configure')}
-
-
- ) : null}
-
-
- {null}
-
-
-
- {!isLoading &&
- stats.projectsWithReposCount &&
- stats.projectsWithReposCount !== stats.totalProjects ? (
-
-
- {t('Handoff all to:')}
-
- ({
- value:
- typeof option.value === 'string'
- ? option.value
- : (option.value.id ?? ''),
- label: option.label,
- }))}
- value="1"
- onChange={() => {
- // mutateSelectedAgent(option.value, {
- }}
- />
-
- ) : null}
-
-
-
- {!isLoading && stats.projectsWithReposCount ? (
-
-
- {t('Update all projects to:')}
-
-
- }
- disabled={stats.projectsWithReposCount === stats.totalProjects}
- onClick={() => {
- // TODO
- }}
- >
- {t('Enabled')}
-
- }
- disabled={stats.projectsWithReposCount === 0}
- onClick={() => {
- // TODO
- }}
- >
- {t('Disabled')}
-
-
-
- ) : null}
-
-
- );
-}
-
-export function CodeReviewOverviewSection({stats, isLoading}: Props) {
- const organization = useOrganization();
-
- return (
-
-
- {!isLoading && stats.seerRepoCount > 0 ? (
-
-
- {t('Configure')}
-
-
- ) : null}
-
-
- {!isLoading && stats.seerRepoCount ? (
-
-
- {t('Update all repos to:')}
-
-
- }
- disabled={stats.projectsWithReposCount === stats.totalProjects}
- onClick={() => {
- // TODO
- }}
- >
- {t('Enabled')}
-
- }
- disabled={stats.projectsWithReposCount === 0}
- onClick={() => {
- // TODO
- }}
- >
- {t('Disabled')}
-
-
-
- ) : null}
-
-
- );
-}
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index 516c7658c19001..f202708f902e37 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -15,8 +15,11 @@ import type {Organization} from 'sentry/types/organization';
import {fetchMutation} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader';
+import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
+import {CodeReviewOverviewSection} from 'sentry/views/settings/seer/overview/codeReviewOverviewSection';
import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection';
+import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
import {SeerSettingsPageContent} from 'getsentry/views/seerAutomation/components/seerSettingsPageContent';
import {SeerSettingsPageWrapper} from 'getsentry/views/seerAutomation/components/seerSettingsPageWrapper';
@@ -36,6 +39,7 @@ export function SeerAutomationSettings() {
const canWrite = useCanWriteSettings();
const showSeerOverview = organization.features.includes('seer-overview');
+ const {stats, isLoading} = useSeerOverviewData();
const orgEndpoint = `/organizations/${organization.slug}/`;
const orgMutationOpts = mutationOptions({
@@ -82,6 +86,8 @@ export function SeerAutomationSettings() {
{showSeerOverview ? (
+
+
) : null}
Date: Tue, 24 Mar 2026 16:27:38 -0700
Subject: [PATCH 07/29] Iterate!
---
.../app/components/core/form/layout/index.tsx | 17 +-
.../seer/overview/autofixOverviewSection.tsx | 340 ++++++++++--------
.../overview/codeReviewOverviewSection.tsx | 197 +++++-----
.../settings/seer/overview/components.tsx | 141 +++++++-
.../seer/overview/scmOverviewSection.tsx | 24 +-
.../gsApp/views/seerAutomation/settings.tsx | 325 +++++++++--------
tests/js/fixtures/organization.ts | 2 +
7 files changed, 627 insertions(+), 419 deletions(-)
diff --git a/static/app/components/core/form/layout/index.tsx b/static/app/components/core/form/layout/index.tsx
index aff4d9cb03dff1..3d7c804e71fbde 100644
--- a/static/app/components/core/form/layout/index.tsx
+++ b/static/app/components/core/form/layout/index.tsx
@@ -6,6 +6,7 @@ import {useFieldContext} from '@sentry/scraps/form/formContext';
import {
Container,
Flex,
+ Grid,
Stack,
type FlexProps,
type StackProps,
@@ -28,16 +29,16 @@ function RowLayout(props: RowLayoutProps) {
const field = useFieldContext();
return (
-
-
+
{props.children}
-
+
);
}
@@ -65,9 +66,9 @@ function StackLayout(props: StackLayoutProps) {
const field = useFieldContext();
return (
- {props.hintText}
) : null}
-
+
);
}
@@ -104,7 +105,7 @@ const highlightFade = keyframes`
}
`;
-const HighlightableFlex = styled(Flex)`
+const HighlightableGrid = styled(Grid)`
--highlight-color: ${p => p.theme.tokens.background.transparent.accent.muted};
&[data-highlight] {
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index b7d93404708dd1..1bc8e39b4ca489 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -1,16 +1,20 @@
import {mutationOptions} from '@tanstack/react-query';
import {z} from 'zod';
+import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
-import {AutoSaveForm} from '@sentry/scraps/form';
-import {Flex} from '@sentry/scraps/layout';
-import {Link} from '@sentry/scraps/link';
+import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
+import {FieldMeta} from '@sentry/scraps/form/field/meta';
+import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {ExternalLink, Link} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {hasEveryAccess} from 'sentry/components/acl/access';
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
+import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconSettings} from 'sentry/icons';
-import {t, tn} from 'sentry/locale';
+import {t, tct, tn} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
import {fetchMutation, useQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
@@ -28,17 +32,10 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
const canWrite = hasEveryAccess(['org:write'], {organization});
const schema = z.object({
- defaultCodingAgent: z.string().nullable(),
- defaultCodingAgentIntegrationId: z.number().nullable(),
- defaultAutofixAutomationTuning: z.enum(['off', 'medium']),
+ defaultCodingAgent: z.string(),
+ autoOpenPrs: z.boolean(),
});
- const {data: integrations} = useQuery({
- ...organizationIntegrationsCodingAgents(organization),
- select: data => data.json.integrations ?? [],
- });
- const options = useAgentOptions({integrations: integrations ?? []});
-
const orgMutationOpts = mutationOptions({
mutationFn: (data: Partial) =>
fetchMutation({
@@ -48,161 +45,214 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
}),
onSuccess: updateOrganization,
});
- const autofixTuningMutationOpts = mutationOptions({
- mutationFn: (data: {defaultAutofixAutomationTuning: boolean}) =>
- fetchMutation({
+
+ const {data: integrations} = useQuery({
+ ...organizationIntegrationsCodingAgents(organization),
+ select: data => data.json.integrations ?? [],
+ });
+ const rawAgentOptions = useAgentOptions({integrations: integrations ?? []});
+ const codingAgentOptions = rawAgentOptions.map(option => ({
+ value:
+ option.value === 'seer' || option.value === 'none'
+ ? option.value
+ : option.value.id!,
+ label: option.label,
+ }));
+
+ const codingAgentMutationOpts = mutationOptions({
+ mutationFn: (data: {defaultCodingAgent: string}) => {
+ const selected = data.defaultCodingAgent;
+ return fetchMutation({
method: 'PUT',
url: `/organizations/${organization.slug}/`,
- data: {
- // All values other than 'off' are converted to 'medium'
- defaultAutofixAutomationTuning: data.defaultAutofixAutomationTuning
- ? 'medium'
- : 'off',
- },
- }),
+ data:
+ selected === 'seer'
+ ? {defaultCodingAgent: selected, defaultCodingAgentIntegrationId: null}
+ : selected === 'none'
+ ? {defaultCodingAgent: null, defaultCodingAgentIntegrationId: null}
+ : {
+ defaultCodingAgent: selected,
+ defaultCodingAgentIntegrationId: Number(selected),
+ },
+ });
+ },
onSuccess: updateOrganization,
});
return (
-
-
- {isLoading ? null : (
-
-
- {t('Configure')}
-
-
- )}
-
-
-
-
-
- {
- // TODO
- }}
- >
- {t('Connect Projects to Repos')}
-
-
-
-
-
+
+ {t('Autofix')}
+ {/* ,
+ }
+ )}
+ size="xs"
+ icon="info"
+ /> */}
+
+
+
+ {t('Configure')}
+
+
+
+
+ }
+ >
{field => (
-
-
-
+
+
+
+
+
+
+ {t(
+ '%s of %s existing projects use %s',
+ stats.projectsWithAutomationCount,
+ stats.totalProjects,
+ codingAgentOptions.find(option => option.value === field.state.value)
+ ?.label
+ )}
+
+ {
+ // TODO
+ }}
+ >
+ {tn(
+ 'Set for the existing project',
+ 'Set for %s existing projects',
+ stats.projectsWithReposCount
+ )}
+
+
+
+
)}
-
- {isLoading ? (
-
- ) : (
-
- {
- // TODO
- }}
- >
- {tn('Apply to the project', 'Apply to all %s projects', stats.totalProjects)}
-
-
- )}
-
-
-
{field => (
-
+
+ {tct(
+ 'For all new projects with connected repos, Seer will be able to make pull requests for [docs:highly actionable] issues.',
+ {
+ docs: (
+
+ ),
+ }
+ )}
+
+ {organization.enableSeerCoding === false && (
+
+ {tct(
+ '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
+ {
+ settings: (
+
+ ),
+ }
+ )}
+
+ )}
+
+ }
>
-
-
+
+
+
+
+
+
+ {field.state.value
+ ? t(
+ '%s of %s existing repos have Create PR disabled',
+ stats.totalProjects - stats.projectsWithCreatePrCount,
+ stats.totalProjects
+ )
+ : t(
+ '%s of %s existing repos have Create PR enabled',
+ stats.projectsWithCreatePrCount,
+ stats.totalProjects
+ )}
+
+ {
+ // TODO
+ }}
+ >
+ {field.state.value
+ ? tn(
+ 'Enable for the existing project',
+ 'Enable for %s existing projects',
+ stats.projectsWithReposCount
+ )
+ : tn(
+ 'Disable for the existing project',
+ 'Disable for %s existing projects',
+ stats.projectsWithReposCount
+ )}
+
+
+
+
)}
-
- {isLoading ? (
-
- ) : (
-
- {
- // TODO
- }}
- >
- {organization.defaultAutofixAutomationTuning === 'off'
- ? tn(
- 'Disable for the project',
- 'Disable for all %s projects',
- stats.totalProjects
- )
- : tn(
- 'Enable for the project',
- 'Enable for all %s projects',
- stats.totalProjects
- )}
-
-
- )}
-
+
);
}
diff --git a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
index 137a7a1725f030..96dddd78cc5d90 100644
--- a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
@@ -2,12 +2,15 @@ import {mutationOptions} from '@tanstack/react-query';
import {z} from 'zod';
import {Button} from '@sentry/scraps/button';
-import {AutoSaveForm} from '@sentry/scraps/form';
-import {Flex} from '@sentry/scraps/layout';
+import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
+import {FieldMeta} from '@sentry/scraps/form/field/meta';
+import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
import {Link} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {hasEveryAccess} from 'sentry/components/acl/access';
+import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconSettings} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import {DEFAULT_CODE_REVIEW_TRIGGERS} from 'sentry/types/integrations';
@@ -42,26 +45,31 @@ export function CodeReviewOverviewSection({stats, isLoading}: Props) {
});
return (
-
-
- {isLoading ? null : (
-
-
- {t('Configure')}
-
-
- )}
-
-
-
+
+ {t('Code Review')}
+ {/* ,
+ }
+ )}
+ size="xs"
+ icon="info"
+ /> */}
+
+
+
+ {t('Configure')}
+
+
+
+
+ }
+ >
{field => (
-
-
-
+
+
+
+
+
+
+
+ {field.state.value
+ ? t(
+ '%s of %s existing repos have code review disabled',
+ stats.seerRepoCount - stats.reposWithCodeReviewCount,
+ stats.seerRepoCount
+ )
+ : t(
+ '%s of %s existing repos have code review enabled',
+ stats.reposWithCodeReviewCount,
+ stats.seerRepoCount
+ )}
+
+ {
+ // TODO
+ }}
+ >
+ {field.state.value
+ ? tn(
+ 'Enable for existing repo',
+ 'Enable for %s existing repos',
+ stats.seerRepoCount - stats.reposWithCodeReviewCount
+ )
+ : tn(
+ 'Disable for the existing repo',
+ 'Disable for %s existing repos',
+ stats.seerRepoCount - stats.reposWithCodeReviewCount
+ )}
+
+
+
+
)}
- {isLoading ? (
-
- ) : (
-
- {
- // TODO
- }}
- >
- {organization.autoEnableCodeReview
- ? tn(
- 'Disable for the repo',
- 'Disable for all %s repos',
- stats.seerRepoCount
- )
- : tn('Enable for the repo', 'Enable for all %s repos', stats.seerRepoCount)}
-
-
- )}
-
-
-
{field => (
- }
)}
>
-
-
+
+
+
+
+
+
+ {
+ // TODO
+ }}
+ >
+ {tn(
+ 'Set for the existing repo',
+ 'Set for %s existing repos',
+ stats.seerRepoCount
+ )}
+
+
+
+
)}
-
- {isLoading ? (
-
- ) : (
-
- {
- // TODO
- }}
- >
- {tn('Set for the repo', 'Set for all %s repos', stats.seerRepoCount)}
-
-
- )}
-
+
);
}
diff --git a/static/app/views/settings/seer/overview/components.tsx b/static/app/views/settings/seer/overview/components.tsx
index 02fe8bf57ab9ec..dcbd4849d00a0a 100644
--- a/static/app/views/settings/seer/overview/components.tsx
+++ b/static/app/views/settings/seer/overview/components.tsx
@@ -1,19 +1,30 @@
-import {type ReactNode} from 'react';
+import {Fragment, type ReactNode} from 'react';
+import {keyframes} from '@emotion/react';
+import styled from '@emotion/styled';
-import {Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {FieldMeta} from '@sentry/scraps/form/field/meta';
+import {useFieldContext} from '@sentry/scraps/form/formContext';
+import {Container, Flex, Grid, Stack, type FlexProps} from '@sentry/scraps/layout';
import {Heading, Text} from '@sentry/scraps/text';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
export function SeerOverview({children}: {children: ReactNode}) {
return (
-
+
{children}
);
}
-function Section({children}: {children?: ReactNode}) {
+function Section({children, isNew}: {children?: ReactNode; isNew?: boolean}) {
+ if (isNew) {
+ return (
+
+ {children}
+
+ );
+ }
return (
{label}
-
- {isPending ? : null}
-
- {value}
-
-
+ {/*
+ {typeof value === 'string' ? value : }
+ */}
);
}
+function StatBar({value, outOf}: {outOf: number; value: number}) {
+ const percent = value / outOf;
+ return (
+
+ );
+}
+
function ActionButton({children}: {children: ReactNode}) {
return (
@@ -80,15 +113,93 @@ function ActionButton({children}: {children: ReactNode}) {
);
}
-function formatStatValue(value: number, outOf: number | undefined, isLoading: boolean) {
- if (isLoading) {
- return '\u2014';
- }
- return outOf === undefined ? value : `${value}\u2009/\u2009${outOf}`;
+function formatStatValue(value: number, outOf: number | undefined) {
+ // return outOf === undefined ? value : `${value}\u2009/\u2009${outOf}`;
+
+ return outOf === undefined ? 1.0 : value / outOf;
+}
+
+// const highlightFade = keyframes`
+// 0% {
+// background-color: var(--highlight-color);
+// }
+// 100% {
+// background-color: transparent;
+// }
+// `;
+
+// const HighlightableFlex = styled(Flex)`
+// --highlight-color: ${p => p.theme.tokens.background.transparent.accent.muted};
+
+// &[data-highlight] {
+// animation: ${highlightFade} ${p => p.theme.motion.smooth.slow};
+// }
+// `;
+
+// function RowLayout(props: {
+// children: React.ReactNode;
+// label: React.ReactNode;
+// hintText?: React.ReactNode;
+// padding?: FlexProps<'div'>['padding'];
+// required?: boolean;
+// variant?: 'compact';
+// }) {
+// const isCompact = props.variant === 'compact';
+// const field = useFieldContext();
+
+// return (
+//
+//
+//
+//
+// {props.label}
+//
+//
+// {props.hintText && !isCompact ? (
+// {props.hintText}
+// ) : null}
+//
+
+// {props.children}
+//
+// );
+// }
+
+function Subtitle({children}: {children: ReactNode}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Description({children}: {children: ReactNode}) {
+ return (
+
+
+ {children}
+
+
+ );
}
SeerOverview.Section = Section;
SeerOverview.SectionHeader = SectionHeader;
SeerOverview.Stat = Stat;
+SeerOverview.StatBar = StatBar;
SeerOverview.ActionButton = ActionButton;
SeerOverview.formatStatValue = formatStatValue;
+SeerOverview.Subtitle = Subtitle;
+SeerOverview.Description = Description;
+// SeerOverview.RowLayout = RowLayout;
diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
index 9d57d2186f98e2..b77d0131d40ddb 100644
--- a/static/app/views/settings/seer/overview/scmOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
@@ -123,11 +123,11 @@ export function SCMOverviewSectionView({
const stat = (
);
@@ -136,11 +136,13 @@ export function SCMOverviewSectionView({
{isPending ? null : (
-
-
- {t('Configure')}
-
-
+
+
+
+ {t('Configure')}
+
+
+
)}
{isPending ? (
@@ -172,7 +174,7 @@ export function SCMOverviewSectionView({
) : (
-
+
{stat}
{seerRepos.length === 0 ? (
@@ -184,7 +186,7 @@ export function SCMOverviewSectionView({
onDone={refetchIntegrations}
/>
)}
-
+
)}
);
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index f202708f902e37..e9d284937189f7 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -1,3 +1,4 @@
+import {Fragment} from 'react';
import {mutationOptions} from '@tanstack/react-query';
import {z} from 'zod';
@@ -84,179 +85,191 @@ export function SeerAutomationSettings() {
/>
{showSeerOverview ? (
-
-
+
+
+
+
-
+
) : null}
-
- {t('Default automations for new projects')}
- ,
- }
- )}
- size="xs"
- icon="info"
- />
-
- }
- >
-
- {field => (
-
- ),
- }
+ {!showSeerOverview && (
+
+
+ {t('Default automations for new projects')}
+
+ ),
+ }
+ )}
+ size="xs"
+ icon="info"
+ />
+
+ }
+ >
+
-
-
- )}
-
-
- {field => (
-
- {tct(
- 'For all new projects with connected repos, Seer will be able to make pull requests for [docs:highly actionable] issues.',
+ {field => (
+
),
}
)}
- {organization.enableSeerCoding === false && (
-
- {tct(
- '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
- {
- settings: (
-
- ),
- }
+ >
+
+
+ )}
+
+
+ {field => (
+
+
+ {tct(
+ 'For all new projects with connected repos, Seer will be able to make pull requests for [docs:highly actionable] issues.',
+ {
+ docs: (
+
+ ),
+ }
+ )}
+
+ {organization.enableSeerCoding === false && (
+
+ {tct(
+ '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
+ {
+ settings: (
+
+ ),
+ }
+ )}
+
)}
-
+
+ }
+ >
+
+
+ )}
+
+
+
+ {t('Default Code Review for New Repos')}
+ ,
+ }
)}
-
- }
+ size="xs"
+ icon="info"
+ />
+
+ }
+ >
+
-
-
- )}
-
-
-
- {t('Default Code Review for New Repos')}
- ,
- }
- )}
- size="xs"
- icon="info"
- />
-
- }
- >
-
- {field => (
- (
+
+
+
)}
+
+
-
-
- )}
-
-
- {field => (
- }
+ {field => (
+ }
+ )}
+ >
+
+
)}
- >
-
-
- )}
-
-
+
+
+
+ )}
= {}): Organiz
dateCreated: new Date().toISOString(),
debugFilesRole: '',
defaultCodeReviewTriggers: [],
+ defaultCodingAgent: null,
+ defaultCodingAgentIntegrationId: null,
defaultRole: '',
enhancedPrivacy: false,
eventsMemberAdmin: false,
From 5d35c756ab8220d898be2332f62c8838416630c9 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 24 Mar 2026 21:45:50 -0700
Subject: [PATCH 08/29] Iterate some more
---
.../seer/overview/autofixOverviewSection.tsx | 14 -
.../overview/codeReviewOverviewSection.tsx | 14 -
.../overview/scmOverviewSection.stories.tsx | 154 ++++-----
.../seer/overview/scmOverviewSection.tsx | 304 +++++++++---------
.../seer/overview/seerOverview.stories.tsx | 172 ----------
.../gsApp/views/seerAutomation/settings.tsx | 16 +-
6 files changed, 244 insertions(+), 430 deletions(-)
delete mode 100644 static/app/views/settings/seer/overview/seerOverview.stories.tsx
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index 1bc8e39b4ca489..875e676d1c4279 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -4,7 +4,6 @@ import {z} from 'zod';
import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
-import {FieldMeta} from '@sentry/scraps/form/field/meta';
import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
@@ -12,13 +11,11 @@ import {Text} from '@sentry/scraps/text';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {hasEveryAccess} from 'sentry/components/acl/access';
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconSettings} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
import {fetchMutation, useQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks';
@@ -84,17 +81,6 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
title={
{t('Autofix')}
- {/* ,
- }
- )}
- size="xs"
- icon="info"
- /> */}
diff --git a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
index 96dddd78cc5d90..174fc0abdc9127 100644
--- a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
@@ -3,21 +3,18 @@ import {z} from 'zod';
import {Button} from '@sentry/scraps/button';
import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
-import {FieldMeta} from '@sentry/scraps/form/field/meta';
import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
import {Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {hasEveryAccess} from 'sentry/components/acl/access';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconSettings} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import {DEFAULT_CODE_REVIEW_TRIGGERS} from 'sentry/types/integrations';
import type {Organization} from 'sentry/types/organization';
import {fetchMutation} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
interface Props {
@@ -49,17 +46,6 @@ export function CodeReviewOverviewSection({stats, isLoading}: Props) {
title={
{t('Code Review')}
- {/* ,
- }
- )}
- size="xs"
- icon="info"
- /> */}
diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx
index ebde81604502bb..e5c329df20494d 100644
--- a/static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx
+++ b/static/app/views/settings/seer/overview/scmOverviewSection.stories.tsx
@@ -1,12 +1,11 @@
-import {Fragment} from 'react';
+import {Fragment, type ComponentProps} from 'react';
import * as Storybook from 'sentry/stories';
import type {
IntegrationRepository,
OrganizationIntegration,
} from 'sentry/types/integrations';
-import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
-import {SCMOverviewSectionView} from 'sentry/views/settings/seer/overview/scmOverviewSection';
+import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection';
const GITHUB_INTEGRATION: OrganizationIntegration = {
id: '1',
@@ -38,7 +37,7 @@ const REPOS: IntegrationRepository[] = [
{identifier: 'my-org/infra', name: 'my-org/infra', isInstalled: true},
];
-const BASE_PROPS = {
+const BASE_PROPS: ComponentProps = {
canWrite: true,
organizationSlug: 'my-org',
isError: false,
@@ -70,94 +69,99 @@ export default Storybook.story('SCMOverviewSection', story => {
));
story('Loading', () => (
-
-
-
+
));
story('Error', () => (
-
-
-
+
));
story('No supported integrations installed', () => (
-
-
-
+
));
story('Integration installed, provider has no accessible repos', () => (
-
-
-
+
));
- story('Integration installed, repos visible but none added to Sentry', () => (
-
-
-
+ story('Integration installed, 1 repo visible but none added to Sentry', () => (
+
));
- story('Some repos connected', () => (
-
- ({
- repo,
- integration: GITHUB_INTEGRATION,
- }))}
- />
-
+ story('Integration installed, >1 repos visible but none added to Sentry', () => (
+
));
- story('All repos connected', () => (
-
-
-
+ story('Loading more repos', () => (
+ ({
+ repo,
+ isReposPending: true,
+ integration: GITHUB_INTEGRATION,
+ }))}
+ />
+ ));
+
+ story('1 of 1 repo added', () => (
+
+ ));
+
+ story('Some repos added', () => (
+ ({
+ repo,
+ integration: GITHUB_INTEGRATION,
+ }))}
+ />
+ ));
+
+ story('All repos added', () => (
+
));
story('Read-only (canWrite: false)', () => (
-
- ({
- repo,
- integration: GITHUB_INTEGRATION,
- }))}
- />
-
+ ({
+ repo,
+ integration: GITHUB_INTEGRATION,
+ }))}
+ />
));
});
diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
index b77d0131d40ddb..9170a4cfb9f259 100644
--- a/static/app/views/settings/seer/overview/scmOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx
@@ -1,8 +1,11 @@
-import {Fragment, useMemo, useState} from 'react';
+import {useMemo, useState} from 'react';
import {css} from '@emotion/react';
+import styled from '@emotion/styled';
import {useMutation} from '@tanstack/react-query';
+import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
+import {FieldGroup} from '@sentry/scraps/form';
import {Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
@@ -15,13 +18,13 @@ import {
} from 'sentry/actionCreators/indicator';
import {openModal} from 'sentry/actionCreators/modal';
import {isSupportedAutofixProvider} from 'sentry/components/events/autofix/utils';
-import {LoadingError} from 'sentry/components/loadingError';
+import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {RepoProviderIcon} from 'sentry/components/repositories/repoProviderIcon';
import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink';
import {useScmIntegrationTreeData} from 'sentry/components/repositories/scmIntegrationTree/useScmIntegrationTreeData';
import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
import {IconAdd, IconOpen, IconSettings} from 'sentry/icons';
-import {t, tn} from 'sentry/locale';
+import {t} from 'sentry/locale';
import type {
IntegrationRepository,
OrganizationIntegration,
@@ -29,8 +32,6 @@ import type {
import {defined} from 'sentry/utils';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {fetchMutation} from 'sentry/utils/queryClient';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
interface SCMOverviewSectionData {
connectedRepos: IntegrationRepository[];
@@ -46,7 +47,7 @@ interface SCMOverviewSectionData {
}>;
}
-function useSCMOverviewSection(): SCMOverviewSectionData {
+export function useSCMOverviewSection(): SCMOverviewSectionData {
const {
scmIntegrations,
connectedIdentifiers,
@@ -103,180 +104,172 @@ function useSCMOverviewSection(): SCMOverviewSectionData {
};
}
-interface SCMOverviewSectionViewProps extends SCMOverviewSectionData {
+interface Props extends SCMOverviewSectionData {
canWrite: boolean;
organizationSlug: string;
}
-export function SCMOverviewSectionView({
- canWrite,
- organizationSlug,
- isPending,
- isError,
- isReposPending,
- supportedScmIntegrations,
- seerRepos,
- connectedRepos,
- unconnectedRepos,
- refetchIntegrations,
-}: SCMOverviewSectionViewProps) {
- const stat = (
-
- );
-
+export function SCMOverviewSection(props: Props) {
+ const {
+ isError,
+ isPending,
+ organizationSlug,
+ refetchIntegrations,
+ seerRepos,
+ supportedScmIntegrations,
+ } = props;
return (
-
-
- {isPending ? null : (
-
+
+ {t('Repositories')}
+
{t('Configure')}
- )}
-
+
+ }
+ >
{isPending ? (
- stat
+
+
+
+ {t('Loading source code providers and repositories...')}
+
+
) : isError ? (
-
- {stat}
-
-
-
-
- ) : supportedScmIntegrations.length === 0 ? (
-
+ {t('Retry')}
+
+ }
>
-
-
- {t(
- 'In order for Seer to work you must make sure at least one integration (Github, Gitlab etc) and at least one repo are connected to Sentry.'
- )}
-
-
+ {t('Error loading repositories')}
+
+ ) : supportedScmIntegrations.length === 0 ? (
+
+ ) : seerRepos.length === 0 ? (
+
) : (
-
- {stat}
- {seerRepos.length === 0 ? (
-
- ) : (
-
- )}
-
+
)}
-
+
);
}
-interface Props {
- canWrite: boolean;
-}
-
-export function SCMOverviewSection({canWrite}: Props) {
- const organization = useOrganization();
- const data = useSCMOverviewSection();
+function NoIntegrations({refetchIntegrations}: {refetchIntegrations: () => void}) {
return (
-
+
+ }
+ onClick={() => {
+ openModal(
+ deps => ,
+ {
+ modalCss: css`
+ width: 700px;
+ `,
+ onClose: refetchIntegrations,
+ }
+ );
+ }}
+ >
+ {t('Install an Integration')}
+
+
+ {t(
+ 'In order for Seer to work you must make sure at least one integration and at least one repo added to Sentry.'
+ )}
+
+
);
}
-function InstallIntegrationButton({onClose}: {onClose: () => void}) {
+function NoRepos({supportedScmIntegrations}: Props) {
+ const externalLinks = supportedScmIntegrations
+ .map(integration => getProviderConfigUrl(integration))
+ .filter(defined);
+
return (
- }
- onClick={() => {
- openModal(
- deps => ,
- {
- modalCss: css`
- width: 700px;
- `,
- onClose,
- }
- );
- }}
- >
- {t('Install an Integration')}
-
+
+
+ {t('0 Repositories Added')}
+
+ {externalLinks.length === 0
+ ? t('Configure your provider to allow Sentry to see your repos.')
+ : t('Allow Access so Sentry can see your repos.')}
+
+
+
+
+ {supportedScmIntegrations.map(integration => {
+ const href = getProviderConfigUrl(integration);
+ if (!href) {
+ return null;
+ }
+ return (
+
+
+ e.stopPropagation()}>
+
+
+ {integration.domainName ?? integration.provider.name}
+
+
+
+
+
+ );
+ })}
+
+
+
);
}
-function CreateReposButton({
- seerIntegrations,
-}: {
- seerIntegrations: OrganizationIntegration[];
-}) {
- const externalLinks = seerIntegrations
- .map(integration => getProviderConfigUrl(integration))
- .filter(defined);
- if (externalLinks.length === 0) {
- return (
-
- {t('Configure your provider to allow Sentry to see your repos.')}
-
- );
- }
+function AddedRepos({
+ canWrite,
+ connectedRepos,
+ isReposPending,
+ organizationSlug,
+ refetchIntegrations,
+ seerRepos,
+ unconnectedRepos,
+}: Props) {
return (
-
-
- {t('Allow Access so Sentry can see your repos.')}
-
-
- {seerIntegrations.map(integration => {
- const href = getProviderConfigUrl(integration);
- if (!href) {
- return null;
- }
- return (
-
-
- e.stopPropagation()}>
-
-
- {integration.domainName ?? integration.provider.name}
-
-
-
-
-
- );
- })}
-
-
+
+
+
+ {isReposPending ? : null}
+ {seerRepos.length === 1
+ ? t('1 Repository Added')
+ : t('%s of %s Repositories Added', connectedRepos.length, seerRepos.length)}
+
+
+
+ {t('Repositories shared with Sentry must be added before they can use used.')}
+
+
+
+
);
}
-function ConnectAllReposButton({
+function AddAllReposButton({
organizationSlug,
disabled,
unconnectedRepos,
@@ -336,7 +329,7 @@ function ConnectAllReposButton({
}
return (
-
+
);
}
+
+const AlertRoundBottom = styled(Alert)`
+ border-radius: 0 0 ${p => p.theme.radius.md} ${p => p.theme.radius.md};
+ overflow: hidden;
+`;
+
+const List = styled('ul')`
+ list-style: none;
+ margin: 0;
+ padding: 0;
+`;
diff --git a/static/app/views/settings/seer/overview/seerOverview.stories.tsx b/static/app/views/settings/seer/overview/seerOverview.stories.tsx
deleted file mode 100644
index 1a3b685526a9de..00000000000000
--- a/static/app/views/settings/seer/overview/seerOverview.stories.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import * as Storybook from 'sentry/stories';
-import type {OrganizationIntegration} from 'sentry/types/integrations';
-import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
-import {CodeReviewOverviewSection} from 'sentry/views/settings/seer/overview/codeReviewOverviewSection';
-import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
-import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection';
-import type {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
-
-function OrganizationIntegrationsFixture(
- params: Partial = {}
-): OrganizationIntegration {
- return {
- accountType: '',
- gracePeriodEnd: '',
- organizationIntegrationStatus: 'active',
- domainName: 'github.com',
- icon: 'https://secure.gravatar.com/avatar/8b4cb68e40b74c90427d8262256bd1c8',
- id: '5',
- name: 'NisanthanNanthakumar',
- provider: {
- aspects: {},
- canAdd: true,
- canDisable: false,
- features: ['commits', 'issue-basic'],
- key: 'github',
- name: 'Github',
- slug: 'github',
- },
- status: 'active',
- configData: null,
- configOrganization: [],
- externalId: 'ext-integration-1',
- organizationId: '1',
- ...params,
- };
-}
-
-const seerIntegrationsFixture = [
- OrganizationIntegrationsFixture({id: '1', name: 'Integration A'}),
- OrganizationIntegrationsFixture({id: '2', name: 'Integration B'}),
-];
-
-const baseStats: ReturnType['stats'] = {
- integrationCount: 2,
- scmIntegrationCount: 2,
- seerIntegrations: seerIntegrationsFixture,
- seerIntegrationCount: 2,
- totalRepoCount: 10,
- seerRepoCount: 10, // equal to totalRepoCount: no "Add all repos" button
- reposWithSettingsCount: 10,
- projectsWithReposCount: 6, // equal to totalProjects: no "Handoff all to" CompactSelect
- projectsWithAutomationCount: 6,
- projectsWithCreatePrCount: 6,
- totalProjects: 6,
- reposWithCodeReviewCount: 10, // equal to seerRepoCount
-};
-
-function TestableOverview({
- stats,
- isLoading,
-}: {
- isLoading: boolean;
- stats: ReturnType['stats'];
-}) {
- return (
-
-
-
-
-
- );
-}
-
-export default Storybook.story('SeerOverview', story => {
- story('No alerts (healthy state)', () => (
-
- ));
-
- story('Loading state', () => );
-
- // SCM stories
-
- story('SCM: No SCM integrations installed', () => (
-
- ));
-
- story('SCM: Integrations installed but no repos connected', () => (
-
- ));
-
- story('SCM: Some repos not yet added to Seer', () => (
-
- ));
-
- // Autofix stories
-
- story('Autofix: No projects have repos linked', () => (
-
- ));
-
- story('Autofix: Some projects with repos (partial)', () => (
-
- ));
-
- // Code Review stories
-
- story('Code Review: No repos have code review enabled', () => (
- 0 → ButtonBar visible, shows 0/10
- }}
- isLoading={false}
- />
- ));
-});
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index e9d284937189f7..028d7925f4ae7f 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -18,8 +18,10 @@ import {useOrganization} from 'sentry/utils/useOrganization';
import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader';
import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
import {CodeReviewOverviewSection} from 'sentry/views/settings/seer/overview/codeReviewOverviewSection';
-import {SeerOverview} from 'sentry/views/settings/seer/overview/components';
-import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection';
+import {
+ SCMOverviewSection,
+ useSCMOverviewSection,
+} from 'sentry/views/settings/seer/overview/scmOverviewSection';
import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
import {SeerSettingsPageContent} from 'getsentry/views/seerAutomation/components/seerSettingsPageContent';
@@ -40,6 +42,8 @@ export function SeerAutomationSettings() {
const canWrite = useCanWriteSettings();
const showSeerOverview = organization.features.includes('seer-overview');
+
+ const scmOverviewData = useSCMOverviewSection();
const {stats, isLoading} = useSeerOverviewData();
const orgEndpoint = `/organizations/${organization.slug}/`;
@@ -86,9 +90,11 @@ export function SeerAutomationSettings() {
{showSeerOverview ? (
-
-
-
+
From 70f8adba8cfe451fe33250bb879e297e3e113b1a Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 24 Mar 2026 22:02:06 -0700
Subject: [PATCH 09/29] add autofix section stories
---
.../autofixOverviewSection.stories.tsx | 185 ++++++++++++++++++
.../seer/overview/autofixOverviewSection.tsx | 180 ++++++++---------
2 files changed, 275 insertions(+), 90 deletions(-)
create mode 100644 static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx
new file mode 100644
index 00000000000000..e9a88c7abb26de
--- /dev/null
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx
@@ -0,0 +1,185 @@
+import {Fragment} from 'react';
+
+import * as Storybook from 'sentry/stories';
+import type {Organization} from 'sentry/types/organization';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
+import type {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
+
+type Stats = ReturnType['stats'];
+
+const BASE_STATS: Stats = {
+ integrationCount: 1,
+ scmIntegrationCount: 1,
+ seerIntegrations: [],
+ seerIntegrationCount: 1,
+ totalProjects: 5,
+ projectsWithReposCount: 2,
+ projectsWithAutomationCount: 2,
+ projectsWithCreatePrCount: 1,
+ totalRepoCount: 3,
+ seerRepoCount: 3,
+ reposWithSettingsCount: 2,
+ reposWithCodeReviewCount: 1,
+};
+
+function Wrapper({
+ children,
+ overrides,
+}: {
+ children: React.ReactNode;
+ overrides?: Partial;
+}) {
+ const org = useOrganization();
+ return (
+ {children}
+ );
+}
+
+export default Storybook.story('AutofixOverviewSection', story => {
+ story('Overview', () => (
+
+
+ The displays Autofix settings
+ in the Seer settings overview. It contains two auto-save forms:
+
+
+
+ Default Coding Agent — a select that sets which agent Seer
+ hands off to for new projects. Shows how many existing projects use the
+ currently selected agent, and a bulk-apply button.
+
+
+ Allow Autofix to create PRs — a toggle that enables Autofix to
+ open pull requests by default. Disabled and shows a warning when{' '}
+ enableSeerCoding is false.
+
+
+
+ The stats prop is supplied by the parent via{' '}
+ useSeerOverviewData(). The coding agent options are fetched
+ internally via the integrations API.
+
+
+ ));
+
+ story('Loading', () => (
+
+
+
+ ));
+
+ story('Single project — not configured', () => (
+
+
+
+ ));
+
+ story('Single project — all configured', () => (
+
+
+
+ ));
+
+ story('Many projects — none configured', () => (
+
+
+
+ ));
+
+ story('Many projects — some configured', () => (
+
+
+
+ ));
+
+ story('Many projects — all configured (bulk apply button disabled)', () => (
+
+
+
+ ));
+
+ story('Auto open PRs enabled — shows "have Create PR disabled" count', () => (
+
+
+
+ ));
+
+ story('Auto open PRs disabled — shows "have Create PR enabled" count', () => (
+
+
+
+ ));
+
+ story('Code generation disabled — warning alert shown, PR toggle forced off', () => (
+
+
+
+ ));
+
+ story('Read-only (canWrite: false)', () => (
+
+
+
+ ));
+});
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index 875e676d1c4279..3bce80bfac0ab4 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -132,7 +132,9 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
{
// TODO
}}
@@ -148,97 +150,95 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
)}
-
- {field => (
-
-
- {tct(
- 'For all new projects with connected repos, Seer will be able to make pull requests for [docs:highly actionable] issues.',
- {
- docs: (
-
- ),
+
+
+ {field => (
+
+ ),
+ }
+ )}
+ >
+
+
+
- {organization.enableSeerCoding === false && (
-
- {tct(
- '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
- {
- settings: (
-
- ),
- }
- )}
-
- )}
-
- }
- >
-
-
-
-
-
-
- {field.state.value
- ? t(
- '%s of %s existing repos have Create PR disabled',
- stats.totalProjects - stats.projectsWithCreatePrCount,
- stats.totalProjects
- )
- : t(
- '%s of %s existing repos have Create PR enabled',
- stats.projectsWithCreatePrCount,
- stats.totalProjects
- )}
-
- {
- // TODO
- }}
- >
- {field.state.value
- ? tn(
- 'Enable for the existing project',
- 'Enable for %s existing projects',
- stats.projectsWithReposCount
- )
- : tn(
- 'Disable for the existing project',
- 'Disable for %s existing projects',
- stats.projectsWithReposCount
- )}
-
-
-
-
+ onChange={field.handleChange}
+ disabled={
+ organization.enableSeerCoding === false
+ ? t('Enable Code Generation to allow Autofix to create PRs.')
+ : !canWrite
+ }
+ />
+
+
+
+ {field.state.value
+ ? t(
+ '%s of %s existing repos have Create PR enabled',
+ stats.projectsWithCreatePrCount,
+ stats.totalProjects
+ )
+ : t(
+ '%s of %s existing repos have Create PR disabled',
+ stats.totalProjects - stats.projectsWithCreatePrCount,
+ stats.totalProjects
+ )}
+
+ {
+ // TODO
+ }}
+ >
+ {field.state.value
+ ? tn(
+ 'Enable for the existing project',
+ 'Enable for %s existing projects',
+ stats.projectsWithReposCount
+ )
+ : tn(
+ 'Disable for the existing project',
+ 'Disable for %s existing projects',
+ stats.projectsWithReposCount
+ )}
+
+
+
+
+ )}
+
+ {organization.enableSeerCoding === false && (
+
+ {tct(
+ '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
+ {
+ settings: (
+
+ ),
+ }
+ )}
+
)}
-
+
);
}
From ceff1b5ece743d13ba1a81b845c02720a09f5092 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 24 Mar 2026 22:04:03 -0700
Subject: [PATCH 10/29] rm unused helpers
---
.../settings/seer/overview/components.tsx | 205 ------------------
1 file changed, 205 deletions(-)
delete mode 100644 static/app/views/settings/seer/overview/components.tsx
diff --git a/static/app/views/settings/seer/overview/components.tsx b/static/app/views/settings/seer/overview/components.tsx
deleted file mode 100644
index dcbd4849d00a0a..00000000000000
--- a/static/app/views/settings/seer/overview/components.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import {Fragment, type ReactNode} from 'react';
-import {keyframes} from '@emotion/react';
-import styled from '@emotion/styled';
-
-import {FieldMeta} from '@sentry/scraps/form/field/meta';
-import {useFieldContext} from '@sentry/scraps/form/formContext';
-import {Container, Flex, Grid, Stack, type FlexProps} from '@sentry/scraps/layout';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
-
-export function SeerOverview({children}: {children: ReactNode}) {
- return (
-
- {children}
-
- );
-}
-
-function Section({children, isNew}: {children?: ReactNode; isNew?: boolean}) {
- if (isNew) {
- return (
-
- {children}
-
- );
- }
- return (
-
- {children}
-
- );
-}
-
-function SectionHeader({children, title}: {title: string; children?: ReactNode}) {
- return (
-
-
- {title}
-
- {children}
-
- );
-}
-
-function Stat({
- value,
- label,
- isPending,
-}: {
- isPending: boolean;
- label: string;
- value: string | number;
-}) {
- return (
-
-
- {label}
-
- {/*
- {typeof value === 'string' ? value : }
- */}
-
- );
-}
-
-function StatBar({value, outOf}: {outOf: number; value: number}) {
- const percent = value / outOf;
- return (
-
- );
-}
-
-function ActionButton({children}: {children: ReactNode}) {
- return (
-
- {children}
-
- );
-}
-
-function formatStatValue(value: number, outOf: number | undefined) {
- // return outOf === undefined ? value : `${value}\u2009/\u2009${outOf}`;
-
- return outOf === undefined ? 1.0 : value / outOf;
-}
-
-// const highlightFade = keyframes`
-// 0% {
-// background-color: var(--highlight-color);
-// }
-// 100% {
-// background-color: transparent;
-// }
-// `;
-
-// const HighlightableFlex = styled(Flex)`
-// --highlight-color: ${p => p.theme.tokens.background.transparent.accent.muted};
-
-// &[data-highlight] {
-// animation: ${highlightFade} ${p => p.theme.motion.smooth.slow};
-// }
-// `;
-
-// function RowLayout(props: {
-// children: React.ReactNode;
-// label: React.ReactNode;
-// hintText?: React.ReactNode;
-// padding?: FlexProps<'div'>['padding'];
-// required?: boolean;
-// variant?: 'compact';
-// }) {
-// const isCompact = props.variant === 'compact';
-// const field = useFieldContext();
-
-// return (
-//
-//
-//
-//
-// {props.label}
-//
-//
-// {props.hintText && !isCompact ? (
-// {props.hintText}
-// ) : null}
-//
-
-// {props.children}
-//
-// );
-// }
-
-function Subtitle({children}: {children: ReactNode}) {
- return (
-
- {children}
-
- );
-}
-
-function Description({children}: {children: ReactNode}) {
- return (
-
-
- {children}
-
-
- );
-}
-
-SeerOverview.Section = Section;
-SeerOverview.SectionHeader = SectionHeader;
-SeerOverview.Stat = Stat;
-SeerOverview.StatBar = StatBar;
-SeerOverview.ActionButton = ActionButton;
-SeerOverview.formatStatValue = formatStatValue;
-SeerOverview.Subtitle = Subtitle;
-SeerOverview.Description = Description;
-// SeerOverview.RowLayout = RowLayout;
From d0034ef4662f429af58acc5d464445167f1d5166 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Wed, 25 Mar 2026 08:36:28 -0700
Subject: [PATCH 11/29] tweak styles
---
.../seer/overview/autofixOverviewSection.tsx | 228 +++++++++---------
.../overview/codeReviewOverviewSection.tsx | 147 +++++------
.../gsApp/views/seerAutomation/settings.tsx | 7 +-
3 files changed, 194 insertions(+), 188 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index 3bce80bfac0ab4..5d61ef97777637 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -4,7 +4,7 @@ import {z} from 'zod';
import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
-import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
@@ -104,13 +104,13 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
mutationOptions={codingAgentMutationOpts}
>
{field => (
-
-
+
+
-
-
- {t(
- '%s of %s existing projects use %s',
- stats.projectsWithAutomationCount,
- stats.totalProjects,
- codingAgentOptions.find(option => option.value === field.state.value)
- ?.label
- )}
-
- {
- // TODO
- }}
- >
- {tn(
- 'Set for the existing project',
- 'Set for %s existing projects',
- stats.projectsWithReposCount
- )}
-
-
-
-
+
+
+
+ {
+ // TODO
+ }}
+ >
+ {tn(
+ 'Set for the existing project',
+ 'Set for all existing projects',
+ stats.projectsWithReposCount
+ )}
+
+
+ {t(
+ '%s of %s existing projects use %s',
+ stats.projectsWithAutomationCount,
+ stats.totalProjects,
+ codingAgentOptions.find(option => option.value === field.state.value)
+ ?.label
+ )}
+
+
+
)}
-
-
- {field => (
+
+
+ {field => (
+
-
-
-
-
-
-
- {field.state.value
- ? t(
- '%s of %s existing repos have Create PR enabled',
- stats.projectsWithCreatePrCount,
- stats.totalProjects
- )
- : t(
- '%s of %s existing repos have Create PR disabled',
- stats.totalProjects - stats.projectsWithCreatePrCount,
- stats.totalProjects
- )}
-
- {
- // TODO
- }}
- >
- {field.state.value
- ? tn(
- 'Enable for the existing project',
- 'Enable for %s existing projects',
- stats.projectsWithReposCount
- )
- : tn(
- 'Disable for the existing project',
- 'Disable for %s existing projects',
- stats.projectsWithReposCount
- )}
-
-
-
+
+
+
- )}
-
- {organization.enableSeerCoding === false && (
-
- {tct(
- '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
- {
- settings: (
-
- ),
- }
+
+
+ {
+ // TODO
+ }}
+ >
+ {field.state.value
+ ? tn(
+ 'Enable for the existing project',
+ 'Enable for all existing projects',
+ stats.projectsWithReposCount
+ )
+ : tn(
+ 'Disable for the existing project',
+ 'Disable for all existing projects',
+ stats.projectsWithReposCount
+ )}
+
+
+ {field.state.value
+ ? t(
+ '%s of %s existing repos have Create PR enabled',
+ stats.projectsWithCreatePrCount,
+ stats.totalProjects
+ )
+ : t(
+ '%s of %s existing repos have Create PR disabled',
+ stats.totalProjects - stats.projectsWithCreatePrCount,
+ stats.totalProjects
+ )}
+
+
+
+ {organization.enableSeerCoding === false && (
+
+ {tct(
+ '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
+ {
+ settings: (
+
+ ),
+ }
+ )}
+
)}
-
+
)}
-
+
);
}
diff --git a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
index 174fc0abdc9127..883c43f47e8def 100644
--- a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
@@ -3,7 +3,7 @@ import {z} from 'zod';
import {Button} from '@sentry/scraps/button';
import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
-import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
@@ -63,13 +63,13 @@ export function CodeReviewOverviewSection({stats, isLoading}: Props) {
mutationOptions={orgMutationOpts}
>
{field => (
-
-
+
+
+
-
-
- {field.state.value
- ? t(
- '%s of %s existing repos have code review disabled',
- stats.seerRepoCount - stats.reposWithCodeReviewCount,
- stats.seerRepoCount
- )
- : t(
- '%s of %s existing repos have code review enabled',
- stats.reposWithCodeReviewCount,
- stats.seerRepoCount
- )}
-
- {
- // TODO
- }}
- >
- {field.state.value
- ? tn(
- 'Enable for existing repo',
- 'Enable for %s existing repos',
- stats.seerRepoCount - stats.reposWithCodeReviewCount
- )
- : tn(
- 'Disable for the existing repo',
- 'Disable for %s existing repos',
- stats.seerRepoCount - stats.reposWithCodeReviewCount
- )}
-
-
-
-
+
+ {
+ // TODO
+ }}
+ >
+ {field.state.value
+ ? tn(
+ 'Enable for existing repo',
+ 'Enable for all existing repos',
+ stats.seerRepoCount - stats.reposWithCodeReviewCount
+ )
+ : tn(
+ 'Disable for the existing repo',
+ 'Disable for all existing repos',
+ stats.seerRepoCount - stats.reposWithCodeReviewCount
+ )}
+
+
+ {field.state.value
+ ? t(
+ '%s of %s existing repos have code review enabled',
+ stats.reposWithCodeReviewCount,
+ stats.seerRepoCount
+ )
+ : t(
+ '%s of %s existing repos have code review disabled',
+ stats.seerRepoCount - stats.reposWithCodeReviewCount,
+ stats.seerRepoCount
+ )}
+
+
+
)}
@@ -127,14 +129,14 @@ export function CodeReviewOverviewSection({stats, isLoading}: Props) {
mutationOptions={orgMutationOpts}
>
{field => (
- }
- )}
- >
-
+
+ }
+ )}
+ >
-
-
- {
- // TODO
- }}
- >
- {tn(
- 'Set for the existing repo',
- 'Set for %s existing repos',
- stats.seerRepoCount
- )}
-
-
-
-
+
+
+ {
+ // TODO
+ }}
+ >
+ {tn(
+ 'Set for the existing repo',
+ 'Set for all existing repos',
+ stats.seerRepoCount
+ )}
+
+
+
)}
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index 028d7925f4ae7f..c394961b06058e 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -89,7 +89,7 @@ export function SeerAutomationSettings() {
/>
{showSeerOverview ? (
-
+
-
- ) : null}
- {!showSeerOverview && (
+
+ ) : (
Date: Wed, 25 Mar 2026 09:13:44 -0700
Subject: [PATCH 12/29] show Connect Projects and Repos button
---
.../seer/overview/autofixOverviewSection.tsx | 56 ++++++++++++++++++-
.../overview/codeReviewOverviewSection.tsx | 2 +-
2 files changed, 56 insertions(+), 2 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index 5d61ef97777637..a8f0e26a101a8c 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -1,16 +1,24 @@
+import {css} from '@emotion/react';
import {mutationOptions} from '@tanstack/react-query';
import {z} from 'zod';
import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
-import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
+import {
+ AutoSaveForm,
+ defaultFormOptions,
+ FieldGroup,
+ useScrapsForm,
+} from '@sentry/scraps/form';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
+import {openModal} from 'sentry/actionCreators/modal';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {hasEveryAccess} from 'sentry/components/acl/access';
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
+import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
import {IconSettings} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
@@ -29,6 +37,7 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
const canWrite = hasEveryAccess(['org:write'], {organization});
const schema = z.object({
+ placeholder: z.string(),
defaultCodingAgent: z.string(),
autoOpenPrs: z.boolean(),
});
@@ -91,6 +100,8 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
}
>
+
+
);
}
+
+function ConnectToReposField() {
+ const form = useScrapsForm(defaultFormOptions);
+
+ return (
+
+
+ {field => (
+
+
+
+ {
+ openModal(
+ deps => (
+
+ ),
+ {
+ modalCss: css`
+ width: 700px;
+ `,
+ // onClose: refetchIntegrations,
+ }
+ );
+ }}
+ >
+ {t('Connect Projects and Repos')}
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
index 883c43f47e8def..6d77e5d8d59151 100644
--- a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
@@ -131,7 +131,7 @@ export function CodeReviewOverviewSection({stats, isLoading}: Props) {
{field => (
}
From 45b0e639084bc70d949c2955d4f40c92d059f836 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Fri, 27 Mar 2026 13:22:04 -0700
Subject: [PATCH 13/29] iterate
---
.../app/components/core/form/layout/index.tsx | 17 ++-
.../seer/overview/autofixOverviewSection.tsx | 41 ++++++-
.../overview/codeReviewOverviewSection.tsx | 13 +--
.../seer/overview/useSeerOverviewData.tsx | 105 +++---------------
.../gsApp/views/seerAutomation/settings.tsx | 14 ++-
5 files changed, 81 insertions(+), 109 deletions(-)
diff --git a/static/app/components/core/form/layout/index.tsx b/static/app/components/core/form/layout/index.tsx
index 3d7c804e71fbde..aff4d9cb03dff1 100644
--- a/static/app/components/core/form/layout/index.tsx
+++ b/static/app/components/core/form/layout/index.tsx
@@ -6,7 +6,6 @@ import {useFieldContext} from '@sentry/scraps/form/formContext';
import {
Container,
Flex,
- Grid,
Stack,
type FlexProps,
type StackProps,
@@ -29,16 +28,16 @@ function RowLayout(props: RowLayoutProps) {
const field = useFieldContext();
return (
-
-
+
{props.children}
-
+
);
}
@@ -66,9 +65,9 @@ function StackLayout(props: StackLayoutProps) {
const field = useFieldContext();
return (
- {props.hintText}
) : null}
-
+
);
}
@@ -105,7 +104,7 @@ const highlightFade = keyframes`
}
`;
-const HighlightableGrid = styled(Grid)`
+const HighlightableFlex = styled(Flex)`
--highlight-color: ${p => p.theme.tokens.background.transparent.accent.muted};
&[data-highlight] {
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index a8f0e26a101a8c..fca54b7aec32ef 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -17,16 +17,53 @@ import {Text} from '@sentry/scraps/text';
import {openModal} from 'sentry/actionCreators/modal';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {hasEveryAccess} from 'sentry/components/acl/access';
+import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
import {IconSettings} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
+import {useFetchAllPages} from 'sentry/utils/api/apiFetch';
import {fetchMutation, useQuery} from 'sentry/utils/queryClient';
+import {useInfiniteQuery} from 'sentry/utils/queryClient';
+import {useOrganization} from 'sentry/utils/useOrganization';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks';
+export function useAutofixOverviewData() {
+ const organization = useOrganization();
+
+ // Autofix Data
+ const autofixSettingsResult = useInfiniteQuery({
+ ...bulkAutofixAutomationSettingsInfiniteOptions({organization}),
+ select: ({pages}) => {
+ const autofixItems = pages.flatMap(page => page.json).filter(s => s !== null);
+
+ const projectsWithRepos = autofixItems.filter(settings => settings.reposCount > 0);
+ const projectsWithAutomation = autofixItems.filter(
+ settings => settings.autofixAutomationTuning !== 'off'
+ );
+ const projectsWithCreatePr = autofixItems.filter(
+ settings => settings.automationHandoff?.auto_create_pr
+ );
+
+ return {
+ autofixItems,
+ projectsWithRepos,
+ projectsWithAutomation,
+ projectsWithCreatePr,
+ totalProjects: autofixItems.length ?? 0,
+ projectsWithReposCount: projectsWithRepos.length ?? 0,
+ projectsWithAutomationCount: projectsWithAutomation.length ?? 0,
+ projectsWithCreatePrCount: projectsWithCreatePr.length ?? 0,
+ };
+ },
+ });
+ useFetchAllPages({result: autofixSettingsResult});
+ return autofixSettingsResult;
+}
+
interface Props {
isLoading: boolean;
stats: ReturnType['stats'];
@@ -132,7 +169,7 @@ export function AutofixOverviewSection({stats, isLoading}: Props) {
-
+
-
+
refetch()}
/>
-
-
-
- {t('Configure')}
-
-
-
+
+
+
+ {t('Configure')}
+
+
}
>
diff --git a/static/app/views/settings/seer/overview/useSeerOverviewData.tsx b/static/app/views/settings/seer/overview/useSeerOverviewData.tsx
index 6ed722924e996e..cfe885e8533918 100644
--- a/static/app/views/settings/seer/overview/useSeerOverviewData.tsx
+++ b/static/app/views/settings/seer/overview/useSeerOverviewData.tsx
@@ -1,106 +1,37 @@
-import {useMemo} from 'react';
-import uniqBy from 'lodash/uniqBy';
-
import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
-import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories';
-import {useSeerSupportedProviderIds} from 'sentry/components/events/autofix/utils';
import {useFetchAllPages} from 'sentry/utils/api/apiFetch';
-import {useInfiniteQuery, useQuery} from 'sentry/utils/queryClient';
+import {useInfiniteQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {organizationIntegrationsQueryOptions} from 'sentry/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions';
export function useSeerOverviewData() {
const organization = useOrganization();
- const seerSupportedProviderIds = useSeerSupportedProviderIds();
-
- // SCM Data
- const {data: integrationData, isPending: isIntegrationsPending} = useQuery({
- ...organizationIntegrationsQueryOptions({organization}),
- select: data => {
- const allIntegrations = data.json.filter(i => i !== null);
- const scmIntegrations = allIntegrations.filter(integration =>
- integration.provider.features.includes('commits')
- );
- const seerIntegrations = scmIntegrations.filter(integration =>
- seerSupportedProviderIds.includes(integration.provider.key)
- );
- return {
- integrations: allIntegrations,
- scmIntegrations,
- seerIntegrations,
- };
- },
- });
-
- // Repos Data
- const repositoriesResult = useInfiniteQuery({
- ...organizationRepositoriesInfiniteOptions({
- organization,
- query: {per_page: 100},
- }),
- select: ({pages}) => {
- const allRepos = uniqBy(
- pages.flatMap(page => page.json),
- 'externalId'
- ).filter(repository => repository.externalId);
- const seerRepos = allRepos.filter(r =>
- seerSupportedProviderIds.includes(r.provider.id)
- );
- return {
- allRepos,
- seerRepos,
- reposWithSettings: seerRepos.filter(r => r.settings !== null),
- reposWithCodeReview: seerRepos.filter(r => r.settings?.enabledCodeReview),
- };
- },
- });
- useFetchAllPages({result: repositoriesResult});
- const {data: repositoryData, isPending: isReposPending} = repositoriesResult;
// Autofix Data
const autofixSettingsResult = useInfiniteQuery({
...bulkAutofixAutomationSettingsInfiniteOptions({organization}),
select: ({pages}) => {
const autofixItems = pages.flatMap(page => page.json).filter(s => s !== null);
+
+ const projectsWithRepos = autofixItems.filter(settings => settings.reposCount > 0);
+ const projectsWithAutomation = autofixItems.filter(
+ settings => settings.autofixAutomationTuning !== 'off'
+ );
+ const projectsWithCreatePr = autofixItems.filter(
+ settings => settings.automationHandoff?.auto_create_pr
+ );
+
return {
autofixItems,
- projectsWithRepos: autofixItems.filter(settings => settings.reposCount > 0),
- projectsWithAutomation: autofixItems.filter(
- settings => settings.autofixAutomationTuning !== 'off'
- ),
- projectsWithCreatePr: autofixItems.filter(
- settings => settings.automationHandoff?.auto_create_pr
- ),
+ projectsWithRepos,
+ projectsWithAutomation,
+ projectsWithCreatePr,
+ totalProjects: autofixItems.length ?? 0,
+ projectsWithReposCount: projectsWithRepos.length ?? 0,
+ projectsWithAutomationCount: projectsWithAutomation.length ?? 0,
+ projectsWithCreatePrCount: projectsWithCreatePr.length ?? 0,
};
},
});
useFetchAllPages({result: autofixSettingsResult});
- const {data: autofixData, isPending: isAutofixPending} = autofixSettingsResult;
-
- const stats = useMemo(() => {
- return {
- // SCM Stats
- integrationCount: integrationData?.integrations.length ?? 0,
- scmIntegrationCount: integrationData?.scmIntegrations.length ?? 0,
- seerIntegrations: integrationData?.seerIntegrations ?? [],
- seerIntegrationCount: integrationData?.seerIntegrations.length ?? 0,
-
- // Autofix Stats
- totalProjects: autofixData?.autofixItems.length ?? 0,
- projectsWithReposCount: autofixData?.projectsWithRepos.length ?? 0,
- projectsWithAutomationCount: autofixData?.projectsWithAutomation.length ?? 0,
- projectsWithCreatePrCount: autofixData?.projectsWithCreatePr.length ?? 0,
-
- // Repos Stats
- totalRepoCount: repositoryData?.allRepos.length ?? 0,
- seerRepoCount: repositoryData?.seerRepos.length ?? 0,
- reposWithSettingsCount: repositoryData?.reposWithSettings.length ?? 0,
- reposWithCodeReviewCount: repositoryData?.reposWithCodeReview.length ?? 0,
- };
- }, [integrationData, autofixData, repositoryData]);
-
- return {
- stats,
- isLoading: isIntegrationsPending || isReposPending || isAutofixPending,
- };
+ return autofixSettingsResult;
}
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index e85c37be2398c6..0a6784581918bd 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -16,7 +16,10 @@ import type {Organization} from 'sentry/types/organization';
import {fetchMutation} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader';
-import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
+import {
+ AutofixOverviewSection,
+ useAutofixOverviewData,
+} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
import {
CodeReviewOverviewSection,
useCodeReviewOverviewSection,
@@ -25,7 +28,6 @@ import {
SCMOverviewSection,
useSCMOverviewSection,
} from 'sentry/views/settings/seer/overview/scmOverviewSection';
-import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
import {SeerSettingsPageContent} from 'getsentry/views/seerAutomation/components/seerSettingsPageContent';
import {SeerSettingsPageWrapper} from 'getsentry/views/seerAutomation/components/seerSettingsPageWrapper';
@@ -47,7 +49,7 @@ export function SeerAutomationSettings() {
const showSeerOverview = true; // organization.features.includes('seer-overview');
const scmOverviewData = useSCMOverviewSection();
- const autofixOverviewData = useSeerOverviewData();
+ const autofixOverviewData = useAutofixOverviewData();
const codeReviewOverviewData = useCodeReviewOverviewSection();
const orgEndpoint = `/organizations/${organization.slug}/`;
@@ -99,7 +101,11 @@ export function SeerAutomationSettings() {
/>
{showSeerOverview ? (
-
+
Date: Mon, 30 Mar 2026 08:34:25 -0700
Subject: [PATCH 14/29] iterate - w/ fancy radios
---
static/app/types/organization.tsx | 2 +
.../autofixOverviewSection.stories.tsx | 185 -----
.../seer/overview/autofixOverviewSection.tsx | 755 +++++++++++++-----
.../overview/useSeerOverviewData.spec.tsx | 269 -------
.../seer/overview/useSeerOverviewData.tsx | 37 -
5 files changed, 542 insertions(+), 706 deletions(-)
delete mode 100644 static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx
delete mode 100644 static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx
delete mode 100644 static/app/views/settings/seer/overview/useSeerOverviewData.tsx
diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx
index e38e784599c112..aae25456df48b5 100644
--- a/static/app/types/organization.tsx
+++ b/static/app/types/organization.tsx
@@ -1,3 +1,4 @@
+import type {AutofixStoppingPoint} from 'sentry/components/events/autofix/types';
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
import type {
DatasetSource,
@@ -64,6 +65,7 @@ export interface Organization extends OrganizationSummary {
dataScrubber: boolean;
dataScrubberDefaults: boolean;
debugFilesRole: string;
+ defaultAutomatedRunStoppingPoint: AutofixStoppingPoint;
defaultCodeReviewTriggers: CodeReviewTrigger[];
defaultCodingAgent: string | null;
defaultCodingAgentIntegrationId: number | null;
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx
deleted file mode 100644
index e9a88c7abb26de..00000000000000
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import {Fragment} from 'react';
-
-import * as Storybook from 'sentry/stories';
-import type {Organization} from 'sentry/types/organization';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {OrganizationContext} from 'sentry/views/organizationContext';
-import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection';
-import type {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
-
-type Stats = ReturnType['stats'];
-
-const BASE_STATS: Stats = {
- integrationCount: 1,
- scmIntegrationCount: 1,
- seerIntegrations: [],
- seerIntegrationCount: 1,
- totalProjects: 5,
- projectsWithReposCount: 2,
- projectsWithAutomationCount: 2,
- projectsWithCreatePrCount: 1,
- totalRepoCount: 3,
- seerRepoCount: 3,
- reposWithSettingsCount: 2,
- reposWithCodeReviewCount: 1,
-};
-
-function Wrapper({
- children,
- overrides,
-}: {
- children: React.ReactNode;
- overrides?: Partial;
-}) {
- const org = useOrganization();
- return (
- {children}
- );
-}
-
-export default Storybook.story('AutofixOverviewSection', story => {
- story('Overview', () => (
-
-
- The displays Autofix settings
- in the Seer settings overview. It contains two auto-save forms:
-
-
-
- Default Coding Agent — a select that sets which agent Seer
- hands off to for new projects. Shows how many existing projects use the
- currently selected agent, and a bulk-apply button.
-
-
- Allow Autofix to create PRs — a toggle that enables Autofix to
- open pull requests by default. Disabled and shows a warning when{' '}
- enableSeerCoding is false.
-
-
-
- The stats prop is supplied by the parent via{' '}
- useSeerOverviewData(). The coding agent options are fetched
- internally via the integrations API.
-
-
- ));
-
- story('Loading', () => (
-
-
-
- ));
-
- story('Single project — not configured', () => (
-
-
-
- ));
-
- story('Single project — all configured', () => (
-
-
-
- ));
-
- story('Many projects — none configured', () => (
-
-
-
- ));
-
- story('Many projects — some configured', () => (
-
-
-
- ));
-
- story('Many projects — all configured (bulk apply button disabled)', () => (
-
-
-
- ));
-
- story('Auto open PRs enabled — shows "have Create PR disabled" count', () => (
-
-
-
- ));
-
- story('Auto open PRs disabled — shows "have Create PR enabled" count', () => (
-
-
-
- ));
-
- story('Code generation disabled — warning alert shown, PR toggle forced off', () => (
-
-
-
- ));
-
- story('Read-only (canWrite: false)', () => (
-
-
-
- ));
-});
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index fca54b7aec32ef..db4375461f644a 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -1,4 +1,5 @@
import {css} from '@emotion/react';
+import styled from '@emotion/styled';
import {mutationOptions} from '@tanstack/react-query';
import {z} from 'zod';
@@ -12,23 +13,23 @@ import {
} from '@sentry/scraps/form';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
+import {Radio} from '@sentry/scraps/radio';
import {Text} from '@sentry/scraps/text';
import {openModal} from 'sentry/actionCreators/modal';
import {updateOrganization} from 'sentry/actionCreators/organizations';
-import {hasEveryAccess} from 'sentry/components/acl/access';
import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
import {IconSettings} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
+import type {Project} from 'sentry/types/project';
import {useFetchAllPages} from 'sentry/utils/api/apiFetch';
import {fetchMutation, useQuery} from 'sentry/utils/queryClient';
import {useInfiniteQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
+import {useProjects} from 'sentry/utils/useProjects';
import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks';
export function useAutofixOverviewData() {
@@ -49,14 +50,9 @@ export function useAutofixOverviewData() {
);
return {
- autofixItems,
projectsWithRepos,
projectsWithAutomation,
projectsWithCreatePr,
- totalProjects: autofixItems.length ?? 0,
- projectsWithReposCount: projectsWithRepos.length ?? 0,
- projectsWithAutomationCount: projectsWithAutomation.length ?? 0,
- projectsWithCreatePrCount: projectsWithCreatePr.length ?? 0,
};
},
});
@@ -64,63 +60,23 @@ export function useAutofixOverviewData() {
return autofixSettingsResult;
}
-interface Props {
- isLoading: boolean;
- stats: ReturnType['stats'];
-}
-
-export function AutofixOverviewSection({stats, isLoading}: Props) {
- const organization = useOrganization();
- const canWrite = hasEveryAccess(['org:write'], {organization});
+type Props = ReturnType & {
+ canWrite: boolean;
+ organization: Organization;
+};
- const schema = z.object({
- placeholder: z.string(),
- defaultCodingAgent: z.string(),
- autoOpenPrs: z.boolean(),
- });
+export function AutofixOverviewSection({canWrite, data, isPending, organization}: Props) {
+ const {projects} = useProjects();
- const orgMutationOpts = mutationOptions({
- mutationFn: (data: Partial) =>
- fetchMutation({
- method: 'PUT',
- url: `/organizations/${organization.slug}/`,
- data,
- }),
- onSuccess: updateOrganization,
- });
+ const {
+ projectsWithRepos = [],
+ projectsWithAutomation = [],
+ projectsWithCreatePr = [],
+ } = data ?? {};
- const {data: integrations} = useQuery({
- ...organizationIntegrationsCodingAgents(organization),
- select: data => data.json.integrations ?? [],
- });
- const rawAgentOptions = useAgentOptions({integrations: integrations ?? []});
- const codingAgentOptions = rawAgentOptions.map(option => ({
- value:
- option.value === 'seer' || option.value === 'none'
- ? option.value
- : option.value.id!,
- label: option.label,
- }));
-
- const codingAgentMutationOpts = mutationOptions({
- mutationFn: (data: {defaultCodingAgent: string}) => {
- const selected = data.defaultCodingAgent;
- return fetchMutation({
- method: 'PUT',
- url: `/organizations/${organization.slug}/`,
- data:
- selected === 'seer'
- ? {defaultCodingAgent: selected, defaultCodingAgentIntegrationId: null}
- : selected === 'none'
- ? {defaultCodingAgent: null, defaultCodingAgentIntegrationId: null}
- : {
- defaultCodingAgent: selected,
- defaultCodingAgentIntegrationId: Number(selected),
- },
- });
- },
- onSuccess: updateOrganization,
- });
+ const projectsWithReposCount = projectsWithRepos.length;
+ const projectsWithAutomationCount = projectsWithAutomation.length;
+ const projectsWithCreatePrCount = projectsWithCreatePr.length;
return (
}
>
-
-
-
- {field => (
-
-
-
-
-
-
+
-
- {
- // TODO
- }}
- >
- {tn(
- 'Set for the existing project',
- 'Set for all existing projects',
- stats.projectsWithReposCount
- )}
-
-
- {t(
- '%s of %s existing projects use %s',
- stats.projectsWithAutomationCount,
- stats.totalProjects,
- codingAgentOptions.find(option => option.value === field.state.value)
- ?.label
- )}
-
-
-
- )}
-
-
-
- {field => (
-
-
- ),
- }
- )}
- >
-
-
-
-
+
-
- {
- // TODO
- }}
- >
- {field.state.value
- ? tn(
- 'Enable for the existing project',
- 'Enable for all existing projects',
- stats.projectsWithReposCount
- )
- : tn(
- 'Disable for the existing project',
- 'Disable for all existing projects',
- stats.projectsWithReposCount
- )}
-
-
- {field.state.value
- ? t(
- '%s of %s existing repos have Create PR enabled',
- stats.projectsWithCreatePrCount,
- stats.totalProjects
- )
- : t(
- '%s of %s existing repos have Create PR disabled',
- stats.totalProjects - stats.projectsWithCreatePrCount,
- stats.totalProjects
- )}
-
-
+
- {organization.enableSeerCoding === false && (
-
- {tct(
- '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
- {
- settings: (
-
- ),
- }
- )}
-
- )}
-
- )}
-
+
);
}
-function ConnectToReposField() {
+function ConnectedReposForm({
+ projects,
+ projectsWithReposCount,
+}: {
+ projects: Project[];
+ projectsWithReposCount: number;
+}) {
const form = useScrapsForm(defaultFormOptions);
return (
@@ -306,7 +138,7 @@ function ConnectToReposField() {
@@ -331,9 +163,502 @@ function ConnectToReposField() {
+
+
+
+ {projects.length === 0
+ ? t('No projects found')
+ : projects.length === 1
+ ? projectsWithReposCount === 1
+ ? t('Your existing project has repos connected')
+ : t(
+ 'Your existing project does not have any repos connected',
+ projectsWithReposCount,
+ projects.length
+ )
+ : projects.length === projectsWithReposCount
+ ? t('All of your existing projects have repos connected')
+ : t(
+ '%s of %s existing projects have repos connected',
+ projectsWithReposCount,
+ projects.length
+ )}
+
+
)}
);
}
+
+function AgentNameForm({
+ canWrite,
+ isPending,
+ organization,
+ projects,
+ projectsWithAutomationCount,
+ projectsWithReposCount,
+}: {
+ canWrite: boolean;
+ isPending: boolean;
+ organization: Organization;
+ projects: Project[];
+ projectsWithAutomationCount: number;
+ projectsWithReposCount: number;
+}) {
+ // Coding Agent options
+ const {data: integrations} = useQuery(
+ organizationIntegrationsCodingAgents(organization)
+ );
+ const rawAgentOptions = useAgentOptions({
+ integrations: integrations?.integrations ?? [],
+ });
+ const codingAgentOptions = rawAgentOptions.map(option => ({
+ value:
+ option.value === 'seer' || option.value === 'none'
+ ? option.value
+ : option.value.id!,
+ label: option.label,
+ }));
+
+ // Default Preferred Coding Agent field
+ const codingAgentMutationOpts = mutationOptions({
+ mutationFn: ({agentName}: {agentName: string}) => {
+ return fetchMutation({
+ method: 'PUT',
+ url: `/organizations/${organization.slug}/`,
+ data:
+ agentName === 'seer'
+ ? {defaultCodingAgent: agentName, defaultCodingAgentIntegrationId: null}
+ : agentName === 'none'
+ ? {defaultCodingAgent: null, defaultCodingAgentIntegrationId: null}
+ : {
+ defaultCodingAgent: agentName,
+ defaultCodingAgentIntegrationId: Number(agentName),
+ },
+ });
+ },
+ onSuccess: updateOrganization,
+ });
+
+ const preferredAgentName = organization.defaultCodingAgentIntegrationId
+ ? String(organization.defaultCodingAgentIntegrationId)
+ : organization.defaultCodingAgent
+ ? organization.defaultCodingAgent
+ : 'none';
+
+ return (
+
+ {field => (
+
+
+
+
+
+
+
+
+ {
+ // TODO
+ }}
+ >
+ {tn(
+ 'Set for the existing project',
+ 'Set for all existing projects',
+ projectsWithReposCount
+ )}
+
+
+ {t(
+ '%s of %s existing projects use %s',
+ projectsWithAutomationCount,
+ projects.length,
+ codingAgentOptions.find(option => option.value === field.state.value)
+ ?.label
+ )}
+
+
+
+ )}
+
+ );
+}
+
+function CreatePrForm({
+ canWrite,
+ isPending,
+ organization,
+ projects,
+ projectsWithCreatePrCount,
+ projectsWithReposCount,
+}: {
+ canWrite: boolean;
+ isPending: boolean;
+ organization: Organization;
+ projects: Project[];
+ projectsWithCreatePrCount: number;
+ projectsWithReposCount: number;
+}) {
+ const orgMutationOpts = mutationOptions({
+ mutationFn: (updateData: Partial) =>
+ fetchMutation({
+ method: 'PUT',
+ url: `/organizations/${organization.slug}/`,
+ data: updateData,
+ }),
+ onSuccess: updateOrganization,
+ });
+
+ return (
+
+ {field => (
+
+
+ ),
+ }
+ )}
+ >
+
+
+
+
+
+
+ {
+ // TODO
+ }}
+ >
+ {field.state.value
+ ? tn(
+ 'Enable for the existing project',
+ 'Enable for all existing projects',
+ projectsWithReposCount
+ )
+ : tn(
+ 'Disable for the existing project',
+ 'Disable for all existing projects',
+ projectsWithReposCount
+ )}
+
+
+ {field.state.value
+ ? t(
+ '%s of %s existing repos have Create PR enabled',
+ projectsWithCreatePrCount,
+ projects.length
+ )
+ : t(
+ '%s of %s existing repos have Create PR disabled',
+ projects.length - projectsWithCreatePrCount,
+ projects.length
+ )}
+
+
+
+ {organization.enableSeerCoding === false && (
+
+ {tct(
+ '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.',
+ {
+ settings: (
+
+ ),
+ }
+ )}
+
+ )}
+
+ )}
+
+ );
+}
+
+function StoppingPointForm({organization}: {organization: Organization}) {
+ // const [mockValue, setMockValue] = useState<
+ // | 'off'
+ // | Organization['defaultAutomatedRunStoppingPoint'][keyof Organization['defaultAutomatedRunStoppingPoint']]
+ // >('off');
+
+ const stoppingPointMutationOpts = mutationOptions({
+ mutationFn: ({
+ stoppingPoint,
+ }: {
+ stoppingPoint:
+ | 'off'
+ | Organization['defaultAutomatedRunStoppingPoint'][keyof Organization['defaultAutomatedRunStoppingPoint']];
+ }) => {
+ // setMockValue(stoppingPoint);
+ return fetchMutation({
+ method: 'PUT',
+ url: `/organizations/${organization.slug}/`,
+ data:
+ stoppingPoint === 'off'
+ ? {
+ defaultAutofixAutomationTuning: 'off',
+ defaultAutomatedRunStoppingPoint: null,
+ }
+ : {
+ defaultAutofixAutomationTuning: 'medium',
+ defaultAutomatedRunStoppingPoint: stoppingPoint,
+ },
+ });
+ },
+ onSuccess: updateOrganization,
+ });
+
+ // const initialValue = mockValue;
+ const initialValue =
+ organization.defaultAutofixAutomationTuning === 'off'
+ ? 'off'
+ : (organization.defaultAutomatedRunStoppingPoint ?? 'off');
+
+ console.log({initialValue});
+
+ return (
+
+ {field => (
+
+
+ ),
+ }
+ )}
+ >
+
+ {/* {false && (
+
+ )}
+
+ {false && (
+
+
+
+ {t('No Automation')}
+
+
+ {t('Automate Root Cause Analysis')}
+
+ {organization.autoOpenPrs ? (
+
+ {t('Automate Code Changes and Create PR')}
+
+ ) : (
+
+ {t('Automate Code Changes')}
+
+ )}
+
+
+ )} */}
+
+ >
+ {(baseProps, {indicator}) => (
+
+
+
+ field.handleChange('off')}
+ />
+
+ {t('No Automation')}
+
+
+
+ field.handleChange('root_cause')}
+ />
+
+ {t('Root Cause Analysis')}
+
+
+
+ field.handleChange('code_changes')}
+ />
+
+ {t('Code Changes')}
+
+
+
+ {indicator ?? }
+
+ )}
+
+
+ {/* */}
+
+
+
+ )}
+
+ );
+}
+
+const StoppingPointContainer = styled(Flex, {
+ shouldForwardProp: prop => prop !== 'selectedValue',
+})<{selectedValue: string}>`
+ position: relative;
+ isolation: isolate;
+
+ &::before {
+ content: '';
+ position: absolute;
+ /* Vertically centered through the radio buttons (24px default diameter → 12px center) */
+ top: 12px;
+ /*
+ * Left is fixed at the center of the first item.
+ * Width expands to reach the center of whichever item is selected,
+ * animating left to right. Each label has flex:1 so all three are equal
+ * width W = (100% - 2×gap) / 3. Distance from center of item 1 to:
+ * item 2: W + gap = (100% + gap) / 3
+ * item 3: 2W + 2×gap = (200% + 2×gap) / 3
+ */
+ left: calc((100% - ${p => p.theme.space.md} * 2) / 6);
+ width: ${p => {
+ const gap = p.theme.space.md;
+ switch (p.selectedValue) {
+ case 'root_cause':
+ return `calc((100% + ${gap}) / 3)`;
+ case 'code_changes':
+ case 'open_pr':
+ return `calc((200% + ${gap} * 2) / 3)`;
+ default: // 'off'
+ return '0px';
+ }
+ }};
+ height: 1em;
+ transform: translateY(-50%);
+ background: ${p => p.theme.colors.blue400};
+ pointer-events: none;
+ z-index: -1;
+ transition: width ${p => p.theme.motion.smooth.moderate};
+ }
+`;
+
+const StoppingPointLabel = styled(Stack)`
+ flex: 1;
+ & input {
+ opacity: 1 !important;
+ background: ${p => p.theme.tokens.background.primary};
+ }
+`;
diff --git a/static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx b/static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx
deleted file mode 100644
index abfabb69dcd9d2..00000000000000
--- a/static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx
+++ /dev/null
@@ -1,269 +0,0 @@
-import {OrganizationFixture} from 'sentry-fixture/organization';
-import {RepositoryFixture} from 'sentry-fixture/repository';
-
-import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import type {
- OrganizationIntegration,
- RepositoryWithSettings,
-} from 'sentry/types/integrations';
-import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData';
-
-function RepoWithSettingsFixture(
- params: Partial = {}
-): RepositoryWithSettings {
- return {
- ...RepositoryFixture(),
- settings: null,
- ...params,
- };
-}
-
-function IntegrationFixture(
- params: Partial & {features?: string[]} = {}
-): OrganizationIntegration {
- const {features = ['commits'], ...rest} = params;
- return {
- id: 'integration-1',
- name: 'Test Integration',
- domainName: 'github.com/test',
- icon: null,
- accountType: null,
- gracePeriodEnd: null,
- organizationIntegrationStatus: 'active',
- status: 'active',
- externalId: 'ext-integration-1',
- organizationId: '1',
- configData: null,
- configOrganization: [],
- provider: {
- key: 'github',
- slug: 'github',
- name: 'GitHub',
- canAdd: true,
- canDisable: false,
- features,
- aspects: {},
- },
- ...rest,
- };
-}
-
-describe('useSeerOverviewData', () => {
- const organization = OrganizationFixture({slug: 'org-slug'});
-
- afterEach(() => {
- MockApiClient.clearMockResponses();
- });
-
- function setupMocks({
- repos = [],
- autofixSettings = [],
- integrations = [],
- }: {
- autofixSettings?: Array<{
- autofixAutomationTuning: string | null;
- projectId: string;
- reposCount: number;
- }>;
- integrations?: OrganizationIntegration[];
- repos?: RepositoryWithSettings[];
- } = {}) {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/',
- method: 'GET',
- body: integrations,
- });
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/repos/',
- method: 'GET',
- body: repos,
- });
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/autofix/automation-settings/',
- method: 'GET',
- body: autofixSettings,
- });
- }
-
- it('returns zeroed stats when there are no repos or projects', async () => {
- setupMocks();
-
- const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(result.current.stats).toEqual({
- integrationCount: 0,
- totalRepoCount: 0,
- seerRepoCount: 0,
- reposWithSettingsCount: 0,
- projectsWithReposCount: 0,
- projectsWithAutomationCount: 0,
- totalProjects: 0,
- reposWithCodeReviewCount: 0,
- });
- });
-
- it('counts repos and integrations with commits feature', async () => {
- setupMocks({
- repos: [
- RepoWithSettingsFixture({
- id: '1',
- externalId: 'ext-1',
- integrationId: 'integration-a',
- provider: {id: 'integrations:github', name: 'GitHub'},
- }),
- RepoWithSettingsFixture({
- id: '2',
- externalId: 'ext-2',
- integrationId: 'integration-a',
- provider: {id: 'integrations:github', name: 'GitHub'},
- }),
- RepoWithSettingsFixture({
- id: '3',
- externalId: 'ext-3',
- integrationId: 'integration-b',
- provider: {id: 'integrations:github', name: 'GitHub'},
- }),
- ],
- integrations: [
- IntegrationFixture({id: 'integration-a', features: ['commits', 'issue-basic']}),
- IntegrationFixture({id: 'integration-b', features: ['commits']}),
- IntegrationFixture({id: 'integration-c', features: ['issue-basic']}), // no commits
- ],
- });
-
- const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(result.current.stats.totalRepoCount).toBe(3);
- expect(result.current.stats.seerRepoCount).toBe(3);
- expect(result.current.stats.integrationCount).toBe(2); // only integrations with 'commits'
- });
-
- it('only counts repos with supported providers toward seerRepoCount', async () => {
- setupMocks({
- repos: [
- RepoWithSettingsFixture({
- id: '1',
- externalId: 'ext-1',
- integrationId: 'integration-a',
- provider: {id: 'integrations:github', name: 'GitHub'},
- }),
- RepoWithSettingsFixture({
- id: '2',
- externalId: 'ext-2',
- integrationId: 'integration-b',
- provider: {id: 'integrations:bitbucket', name: 'Bitbucket'}, // unsupported
- }),
- ],
- });
-
- const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(result.current.stats.totalRepoCount).toBe(2);
- expect(result.current.stats.seerRepoCount).toBe(1);
- });
-
- it('counts repos with code review enabled', async () => {
- setupMocks({
- repos: [
- RepoWithSettingsFixture({
- id: '1',
- externalId: 'ext-1',
- integrationId: 'integration-a',
- provider: {id: 'integrations:github', name: 'GitHub'},
- settings: {enabledCodeReview: true, codeReviewTriggers: []},
- }),
- RepoWithSettingsFixture({
- id: '2',
- externalId: 'ext-2',
- integrationId: 'integration-a',
- provider: {id: 'integrations:github', name: 'GitHub'},
- settings: {enabledCodeReview: false, codeReviewTriggers: []},
- }),
- RepoWithSettingsFixture({
- id: '3',
- externalId: 'ext-3',
- integrationId: 'integration-a',
- provider: {id: 'integrations:github', name: 'GitHub'},
- settings: null, // no settings
- }),
- ],
- });
-
- const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(result.current.stats.reposWithCodeReviewCount).toBe(1);
- });
-
- it('counts projects with repos and with automation enabled', async () => {
- setupMocks({
- autofixSettings: [
- {projectId: '1', reposCount: 2, autofixAutomationTuning: 'medium'},
- {projectId: '2', reposCount: 1, autofixAutomationTuning: 'off'},
- {projectId: '3', reposCount: 0, autofixAutomationTuning: 'off'},
- {projectId: '4', reposCount: 0, autofixAutomationTuning: 'medium'},
- ],
- });
-
- const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(result.current.stats.totalProjects).toBe(4);
- expect(result.current.stats.projectsWithReposCount).toBe(2);
- expect(result.current.stats.projectsWithAutomationCount).toBe(2);
- });
-
- it('counts all non-off automation tuning values as enabled', async () => {
- setupMocks({
- autofixSettings: [
- {projectId: '1', reposCount: 1, autofixAutomationTuning: 'medium'},
- {projectId: '2', reposCount: 1, autofixAutomationTuning: 'high'},
- {projectId: '3', reposCount: 1, autofixAutomationTuning: 'always'},
- {projectId: '4', reposCount: 0, autofixAutomationTuning: 'off'},
- {projectId: '5', reposCount: 0, autofixAutomationTuning: null},
- ],
- });
-
- const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- // only 'off' means disabled; null (deprecated) is also treated as enabled
- expect(result.current.stats.projectsWithAutomationCount).toBe(4);
- });
-
- it('deduplicates repos by externalId', async () => {
- setupMocks({
- repos: [
- RepoWithSettingsFixture({
- id: '1',
- externalId: 'same-external-id',
- integrationId: 'integration-a',
- provider: {id: 'integrations:github', name: 'GitHub'},
- }),
- RepoWithSettingsFixture({
- id: '2',
- externalId: 'same-external-id', // duplicate
- integrationId: 'integration-b',
- provider: {id: 'integrations:github', name: 'GitHub'},
- }),
- ],
- });
-
- const {result} = renderHookWithProviders(useSeerOverviewData, {organization});
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(result.current.stats.totalRepoCount).toBe(1);
- expect(result.current.stats.seerRepoCount).toBe(1);
- });
-});
diff --git a/static/app/views/settings/seer/overview/useSeerOverviewData.tsx b/static/app/views/settings/seer/overview/useSeerOverviewData.tsx
deleted file mode 100644
index cfe885e8533918..00000000000000
--- a/static/app/views/settings/seer/overview/useSeerOverviewData.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
-import {useFetchAllPages} from 'sentry/utils/api/apiFetch';
-import {useInfiniteQuery} from 'sentry/utils/queryClient';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-export function useSeerOverviewData() {
- const organization = useOrganization();
-
- // Autofix Data
- const autofixSettingsResult = useInfiniteQuery({
- ...bulkAutofixAutomationSettingsInfiniteOptions({organization}),
- select: ({pages}) => {
- const autofixItems = pages.flatMap(page => page.json).filter(s => s !== null);
-
- const projectsWithRepos = autofixItems.filter(settings => settings.reposCount > 0);
- const projectsWithAutomation = autofixItems.filter(
- settings => settings.autofixAutomationTuning !== 'off'
- );
- const projectsWithCreatePr = autofixItems.filter(
- settings => settings.automationHandoff?.auto_create_pr
- );
-
- return {
- autofixItems,
- projectsWithRepos,
- projectsWithAutomation,
- projectsWithCreatePr,
- totalProjects: autofixItems.length ?? 0,
- projectsWithReposCount: projectsWithRepos.length ?? 0,
- projectsWithAutomationCount: projectsWithAutomation.length ?? 0,
- projectsWithCreatePrCount: projectsWithCreatePr.length ?? 0,
- };
- },
- });
- useFetchAllPages({result: autofixSettingsResult});
- return autofixSettingsResult;
-}
From 1f98574f55301d798abdd2f3c7a1fa6ea30e0e8e Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Mon, 30 Mar 2026 14:05:59 -0700
Subject: [PATCH 15/29] fix create pr counts
---
static/app/types/organization.tsx | 4 +-
.../seer/overview/autofixOverviewSection.tsx | 235 +++++++++---------
.../views/settings/seer/seerAgentHooks.tsx | 1 -
tests/js/fixtures/organization.ts | 1 +
4 files changed, 115 insertions(+), 126 deletions(-)
diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx
index aae25456df48b5..00f50a0c8afc3c 100644
--- a/static/app/types/organization.tsx
+++ b/static/app/types/organization.tsx
@@ -65,10 +65,10 @@ export interface Organization extends OrganizationSummary {
dataScrubber: boolean;
dataScrubberDefaults: boolean;
debugFilesRole: string;
- defaultAutomatedRunStoppingPoint: AutofixStoppingPoint;
+ defaultAutomatedRunStoppingPoint: 'off' | AutofixStoppingPoint;
defaultCodeReviewTriggers: CodeReviewTrigger[];
defaultCodingAgent: string | null;
- defaultCodingAgentIntegrationId: number | null;
+ defaultCodingAgentIntegrationId: string | number | null;
defaultRole: string;
enhancedPrivacy: boolean;
eventsMemberAdmin: boolean;
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index db4375461f644a..87cb894b797d75 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -35,23 +35,39 @@ import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks';
export function useAutofixOverviewData() {
const organization = useOrganization();
- // Autofix Data
const autofixSettingsResult = useInfiniteQuery({
...bulkAutofixAutomationSettingsInfiniteOptions({organization}),
select: ({pages}) => {
const autofixItems = pages.flatMap(page => page.json).filter(s => s !== null);
-
const projectsWithRepos = autofixItems.filter(settings => settings.reposCount > 0);
- const projectsWithAutomation = autofixItems.filter(
- settings => settings.autofixAutomationTuning !== 'off'
- );
- const projectsWithCreatePr = autofixItems.filter(
- settings => settings.automationHandoff?.auto_create_pr
- );
+ const projectsWithPreferredAgent =
+ organization.defaultCodingAgent === 'seer'
+ ? autofixItems.filter(settings => !settings.automationHandoff)
+ : autofixItems.filter(
+ settings =>
+ String(settings.automationHandoff?.integration_id ?? '') ===
+ String(organization.defaultCodingAgentIntegrationId ?? '')
+ );
+
+ console.log({autoOpenPrs: organization.autoOpenPrs, autofixItems});
+
+ const projectsWithCreatePr = organization.autoOpenPrs
+ ? autofixItems.filter(
+ settings =>
+ (settings.automationHandoff === null &&
+ settings.automatedRunStoppingPoint === 'open_pr') ||
+ settings.automationHandoff?.auto_create_pr
+ )
+ : autofixItems.filter(
+ settings =>
+ settings.automatedRunStoppingPoint !== 'open_pr' &&
+ !settings.automationHandoff?.auto_create_pr
+ );
+ console.log({projectsWithCreatePr});
return {
projectsWithRepos,
- projectsWithAutomation,
+ projectsWithPreferredAgent,
projectsWithCreatePr,
};
},
@@ -70,13 +86,11 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization}
const {
projectsWithRepos = [],
- projectsWithAutomation = [],
+ projectsWithPreferredAgent = [],
projectsWithCreatePr = [],
} = data ?? {};
const projectsWithReposCount = projectsWithRepos.length;
- const projectsWithAutomationCount = projectsWithAutomation.length;
- const projectsWithCreatePrCount = projectsWithCreatePr.length;
return (
@@ -171,13 +183,9 @@ function ConnectedReposForm({
: projects.length === 1
? projectsWithReposCount === 1
? t('Your existing project has repos connected')
- : t(
- 'Your existing project does not have any repos connected',
- projectsWithReposCount,
- projects.length
- )
+ : t('Your existing project does not have any repos connected')
: projects.length === projectsWithReposCount
- ? t('All of your existing projects have repos connected')
+ ? t('All existing projects have repos connected')
: t(
'%s of %s existing projects have repos connected',
projectsWithReposCount,
@@ -197,17 +205,14 @@ function AgentNameForm({
isPending,
organization,
projects,
- projectsWithAutomationCount,
- projectsWithReposCount,
+ projectsWithPreferredAgentCount,
}: {
canWrite: boolean;
isPending: boolean;
organization: Organization;
projects: Project[];
- projectsWithAutomationCount: number;
- projectsWithReposCount: number;
+ projectsWithPreferredAgentCount: number;
}) {
- // Coding Agent options
const {data: integrations} = useQuery(
organizationIntegrationsCodingAgents(organization)
);
@@ -215,44 +220,47 @@ function AgentNameForm({
integrations: integrations?.integrations ?? [],
});
const codingAgentOptions = rawAgentOptions.map(option => ({
- value:
- option.value === 'seer' || option.value === 'none'
- ? option.value
- : option.value.id!,
+ value: option.value === 'seer' ? 'seer' : String(option.value.id),
label: option.label,
}));
- // Default Preferred Coding Agent field
const codingAgentMutationOpts = mutationOptions({
- mutationFn: ({agentName}: {agentName: string}) => {
+ mutationFn: ({agentId}: {agentId: string}) => {
return fetchMutation({
method: 'PUT',
url: `/organizations/${organization.slug}/`,
data:
- agentName === 'seer'
- ? {defaultCodingAgent: agentName, defaultCodingAgentIntegrationId: null}
- : agentName === 'none'
- ? {defaultCodingAgent: null, defaultCodingAgentIntegrationId: null}
- : {
- defaultCodingAgent: agentName,
- defaultCodingAgentIntegrationId: Number(agentName),
- },
+ agentId === 'seer'
+ ? {
+ defaultCodingAgent: agentId,
+ defaultCodingAgentIntegrationId: null,
+ }
+ : {
+ defaultCodingAgent: rawAgentOptions
+ .filter(option => option.value !== 'seer')
+ .find(option => option.value.id === agentId)?.value.provider,
+ defaultCodingAgentIntegrationId: agentId,
+ },
});
},
onSuccess: updateOrganization,
});
- const preferredAgentName = organization.defaultCodingAgentIntegrationId
+ const preferredAgentValue = organization.defaultCodingAgentIntegrationId
? String(organization.defaultCodingAgentIntegrationId)
: organization.defaultCodingAgent
? organization.defaultCodingAgent
- : 'none';
+ : 'seer';
+
+ const preferredAgentLabel = codingAgentOptions.find(
+ option => option.value === preferredAgentValue
+ )?.label;
return (
{field => (
@@ -260,7 +268,7 @@ function AgentNameForm({
@@ -277,7 +285,7 @@ function AgentNameForm({
{
// TODO
}}
@@ -285,17 +293,24 @@ function AgentNameForm({
{tn(
'Set for the existing project',
'Set for all existing projects',
- projectsWithReposCount
+ projectsWithPreferredAgentCount
)}
- {t(
- '%s of %s existing projects use %s',
- projectsWithAutomationCount,
- projects.length,
- codingAgentOptions.find(option => option.value === field.state.value)
- ?.label
- )}
+ {projects.length === 0
+ ? t('No projects found')
+ : projects.length === 1
+ ? projectsWithPreferredAgentCount === 1
+ ? t('Your existing project uses %s', preferredAgentLabel)
+ : t('Your existing project does not use %s', preferredAgentLabel)
+ : projects.length === projectsWithPreferredAgentCount
+ ? t('All existing projects use %s', preferredAgentLabel)
+ : t(
+ '%s of %s existing projects use %s',
+ projectsWithPreferredAgentCount,
+ projects.length,
+ preferredAgentLabel
+ )}
@@ -310,14 +325,12 @@ function CreatePrForm({
organization,
projects,
projectsWithCreatePrCount,
- projectsWithReposCount,
}: {
canWrite: boolean;
isPending: boolean;
organization: Organization;
projects: Project[];
projectsWithCreatePrCount: number;
- projectsWithReposCount: number;
}) {
const orgMutationOpts = mutationOptions({
mutationFn: (updateData: Partial) =>
@@ -340,14 +353,11 @@ function CreatePrForm({
- ),
- }
- )}
+ hintText={tct('For new projects, create a PR when proposing a code change.', {
+ docs: (
+
+ ),
+ })}
>
{
// TODO
@@ -381,26 +391,36 @@ function CreatePrForm({
? tn(
'Enable for the existing project',
'Enable for all existing projects',
- projectsWithReposCount
+ projectsWithCreatePrCount
)
: tn(
'Disable for the existing project',
'Disable for all existing projects',
- projectsWithReposCount
+ projectsWithCreatePrCount
)}
- {field.state.value
- ? t(
- '%s of %s existing repos have Create PR enabled',
- projectsWithCreatePrCount,
- projects.length
- )
- : t(
- '%s of %s existing repos have Create PR disabled',
- projects.length - projectsWithCreatePrCount,
- projects.length
- )}
+ {projects.length === 0
+ ? t('No projects found')
+ : projects.length === 1
+ ? projectsWithCreatePrCount === 1
+ ? t('Your existing project has Create PR enabled')
+ : t('Your existing project does not have Create PR enabled')
+ : field.state.value
+ ? projects.length === projectsWithCreatePrCount
+ ? t('All existing projects have Create PR enabled')
+ : t(
+ '%s of %s existing projects have Create PR enabled',
+ projectsWithCreatePrCount,
+ projects.length
+ )
+ : projects.length === projectsWithCreatePrCount
+ ? t('All existing projects have Create PR disabled')
+ : t(
+ '%s of %s existing projects have Create PR disabled',
+ projectsWithCreatePrCount,
+ projects.length
+ )}
@@ -423,11 +443,6 @@ function CreatePrForm({
}
function StoppingPointForm({organization}: {organization: Organization}) {
- // const [mockValue, setMockValue] = useState<
- // | 'off'
- // | Organization['defaultAutomatedRunStoppingPoint'][keyof Organization['defaultAutomatedRunStoppingPoint']]
- // >('off');
-
const stoppingPointMutationOpts = mutationOptions({
mutationFn: ({
stoppingPoint,
@@ -436,7 +451,6 @@ function StoppingPointForm({organization}: {organization: Organization}) {
| 'off'
| Organization['defaultAutomatedRunStoppingPoint'][keyof Organization['defaultAutomatedRunStoppingPoint']];
}) => {
- // setMockValue(stoppingPoint);
return fetchMutation({
method: 'PUT',
url: `/organizations/${organization.slug}/`,
@@ -455,19 +469,15 @@ function StoppingPointForm({organization}: {organization: Organization}) {
onSuccess: updateOrganization,
});
- // const initialValue = mockValue;
const initialValue =
organization.defaultAutofixAutomationTuning === 'off'
? 'off'
: (organization.defaultAutomatedRunStoppingPoint ?? 'off');
- console.log({initialValue});
-
return (
),
}
@@ -521,31 +531,7 @@ function StoppingPointForm({organization}: {organization: Organization}) {
}
/>
)}
-
- {false && (
-
-
-
- {t('No Automation')}
-
-
- {t('Automate Root Cause Analysis')}
-
- {organization.autoOpenPrs ? (
-
- {t('Automate Code Changes and Create PR')}
-
- ) : (
-
- {t('Automate Code Changes')}
-
- )}
-
-
- )} */}
+ */}
>
{(baseProps, {indicator}) => (
@@ -560,7 +546,7 @@ function StoppingPointForm({organization}: {organization: Organization}) {
field.handleChange('off')}
/>
@@ -571,13 +557,14 @@ function StoppingPointForm({organization}: {organization: Organization}) {
field.handleChange('root_cause')}
/>
{t('Root Cause Analysis')}
+
field.handleChange('code_changes')}
/>
- {t('Code Changes')}
+ {t('Propose Code Changes')}
@@ -625,13 +612,16 @@ const StoppingPointContainer = styled(Flex, {
position: absolute;
/* Vertically centered through the radio buttons (24px default diameter → 12px center) */
top: 12px;
+ transform: translateY(-50%);
+ height: 1em;
+
/*
* Left is fixed at the center of the first item.
* Width expands to reach the center of whichever item is selected,
* animating left to right. Each label has flex:1 so all three are equal
- * width W = (100% - 2×gap) / 3. Distance from center of item 1 to:
+ * width W = (100% - 2*gap) / 3. Distance from center of item 1 to:
* item 2: W + gap = (100% + gap) / 3
- * item 3: 2W + 2×gap = (200% + 2×gap) / 3
+ * item 3: 2W + 2*gap = (200% + 2*gap) / 3
*/
left: calc((100% - ${p => p.theme.space.md} * 2) / 6);
width: ${p => {
@@ -646,8 +636,7 @@ const StoppingPointContainer = styled(Flex, {
return '0px';
}
}};
- height: 1em;
- transform: translateY(-50%);
+
background: ${p => p.theme.colors.blue400};
pointer-events: none;
z-index: -1;
diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx
index 4c19a50cf6410d..0729f92255fec3 100644
--- a/static/app/views/settings/seer/seerAgentHooks.tsx
+++ b/static/app/views/settings/seer/seerAgentHooks.tsx
@@ -32,7 +32,6 @@ export function useAgentOptions({
value: integration,
label: integration.name,
})),
- {value: 'none' as const, label: t('No Handoff')},
];
}, [integrations]);
}
diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts
index 427f3ff908b35d..d099241e26f6ee 100644
--- a/tests/js/fixtures/organization.ts
+++ b/tests/js/fixtures/organization.ts
@@ -57,6 +57,7 @@ export function OrganizationFixture(params: Partial = {}): Organiz
defaultCodeReviewTriggers: [],
defaultCodingAgent: null,
defaultCodingAgentIntegrationId: null,
+ defaultAutomatedRunStoppingPoint: 'off',
defaultRole: '',
enhancedPrivacy: false,
eventsMemberAdmin: false,
From defedcbfb82e65201aab99ee72525ef4207cb9a9 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Mon, 30 Mar 2026 15:27:05 -0700
Subject: [PATCH 16/29] fix test
---
.../settings/seer/overview/autofixOverviewSection.spec.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx
index f53db948f895e5..9c434d25068789 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx
@@ -417,9 +417,9 @@ describe('autofixOverviewSection', () => {
it('shows "No projects found" when there are no projects', async () => {
renderSection([], {projects: []});
- // Both AgentNameForm and CreatePrForm render this text, so use findAllByText
+ // Each form section renders this text, so use findAllByText
const messages = await screen.findAllByText('No projects found');
- expect(messages).toHaveLength(2);
+ expect(messages.length).toBeGreaterThanOrEqual(2);
});
it('shows "Your existing project uses Seer Agent" when 1 project uses preferred agent', async () => {
From d840e5f45c194568f7f2d22779bd06aab2ea3026 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Mon, 30 Mar 2026 15:41:49 -0700
Subject: [PATCH 17/29] add bulk-edit of ser agent name
---
.../seer/overview/autofixOverviewSection.tsx | 46 ++-
.../settings/seer/seerAgentHooks.spec.tsx | 312 +++++++++++++++++-
.../views/settings/seer/seerAgentHooks.tsx | 81 ++++-
.../gsApp/views/seerAutomation/settings.tsx | 2 +-
4 files changed, 427 insertions(+), 14 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index 99650e296bd4fc..c98280084a5c5f 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -18,7 +18,10 @@ import {Text} from '@sentry/scraps/text';
import {openModal} from 'sentry/actionCreators/modal';
import {updateOrganization} from 'sentry/actionCreators/organizations';
-import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
+import {
+ bulkAutofixAutomationSettingsInfiniteOptions,
+ type AutofixAutomationSettings,
+} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
import {IconSettings} from 'sentry/icons';
@@ -30,7 +33,10 @@ import {fetchMutation, useQuery} from 'sentry/utils/queryClient';
import {useInfiniteQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useProjects} from 'sentry/utils/useProjects';
-import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks';
+import {
+ useAgentOptions,
+ useBulkMutateSelectedAgent,
+} from 'sentry/views/settings/seer/seerAgentHooks';
export function useAutofixOverviewData() {
const organization = useOrganization();
@@ -114,7 +120,7 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization}
isPending={isPending}
organization={organization}
projects={projects}
- projectsWithPreferredAgentCount={projectsWithPreferredAgent.length}
+ projectsWithPreferredAgent={projectsWithPreferredAgent}
/>
option.value === preferredAgentValue
)?.label;
+ const preferredAgentIntegration =
+ preferredAgentValue === 'seer'
+ ? ('seer' as const)
+ : (rawAgentOptions
+ .filter(option => option.value !== 'seer')
+ .find(option => option.value.id === preferredAgentValue)?.value ?? 'seer');
+
+ const preferredAgentProjectIds = new Set(
+ projectsWithPreferredAgent.map(s => s.projectId)
+ );
+ const projectsToUpdate = projects.filter(p => !preferredAgentProjectIds.has(p.id));
+
+ const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({
+ projects: projectsToUpdate,
+ });
+
return (
{
- // TODO
+ bulkMutateSelectedAgent(preferredAgentIntegration, {});
}}
>
{tn(
'Set for the existing project',
'Set for all existing projects',
- projectsWithPreferredAgentCount
+ projectsWithPreferredAgent.length
)}
{projects.length === 0
? t('No projects found')
: projects.length === 1
- ? projectsWithPreferredAgentCount === 1
+ ? projectsWithPreferredAgent.length === 1
? t('Your existing project uses %s', preferredAgentLabel)
: t('Your existing project does not use %s', preferredAgentLabel)
- : projects.length === projectsWithPreferredAgentCount
+ : projects.length === projectsWithPreferredAgent.length
? t('All existing projects use %s', preferredAgentLabel)
: t(
'%s of %s existing projects use %s',
- projectsWithPreferredAgentCount,
+ projectsWithPreferredAgent.length,
projects.length,
preferredAgentLabel
)}
diff --git a/static/app/views/settings/seer/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx
index d7a0f5eaedb191..7dae2df5a8fd02 100644
--- a/static/app/views/settings/seer/seerAgentHooks.spec.tsx
+++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx
@@ -14,6 +14,7 @@ import {ProjectsStore} from 'sentry/stores/projectsStore';
import {useQueryClient} from 'sentry/utils/queryClient';
import {
useAgentOptions,
+ useBulkMutateSelectedAgent,
useMutateCreatePr,
useMutateSelectedAgent,
useSelectedAgentFromBulkSettings,
@@ -35,7 +36,7 @@ describe('seerAgentHooks', () => {
});
describe('useAgentOptions', () => {
- it('returns Seer, integration options, and No Handoff Selection', () => {
+ it('returns Seer, integration options', () => {
const integrations: CodingAgentIntegration[] = [
{id: '42', name: 'Cursor', provider: 'cursor'},
];
@@ -616,6 +617,315 @@ describe('seerAgentHooks', () => {
});
});
+ describe('useBulkMutateSelectedAgent', () => {
+ const project1 = ProjectFixture({slug: 'project-slug', id: '1'});
+ const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'});
+ const projects = [project1, project2];
+
+ const basePreference: ProjectSeerPreferences = {
+ repositories: [],
+ automated_run_stopping_point: 'code_changes',
+ automation_handoff: undefined,
+ };
+
+ function setupMocks(preference: ProjectSeerPreferences = basePreference) {
+ const mocks = projects.map(p => ({
+ seerPreferencesGetRequest: MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {preference, code_mapping_repos: []} satisfies SeerPreferencesResponse,
+ }),
+ projectPutRequest: MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/`,
+ method: 'PUT',
+ body: p,
+ }),
+ seerPreferencesPostRequest: MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'POST',
+ body: {},
+ }),
+ }));
+ return {
+ seerPreferencesGetRequests: mocks.map(m => m.seerPreferencesGetRequest),
+ projectPutRequests: mocks.map(m => m.projectPutRequest),
+ seerPreferencesPostRequests: mocks.map(m => m.seerPreferencesPostRequest),
+ };
+ }
+
+ function renderBulkMutateSelectedAgent() {
+ return renderHookWithProviders(
+ (props: {projects: typeof projects}) => {
+ const mutate = useBulkMutateSelectedAgent(props);
+ return {mutate};
+ },
+ {
+ initialProps: {projects},
+ organization,
+ }
+ );
+ }
+
+ beforeEach(() => {
+ ProjectsStore.loadInitialData(projects);
+ });
+
+ it('sends correct API requests to all projects when integration is "seer"', async () => {
+ const {projectPutRequests, seerPreferencesPostRequests} = setupMocks();
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate('seer', {});
+ });
+
+ await waitFor(() => {
+ expect(projectPutRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((p, i) => {
+ expect(projectPutRequests[i]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${p.slug}/`,
+ expect.objectContaining({
+ method: 'PUT',
+ data: {autofixAutomationTuning: 'medium'},
+ })
+ );
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ expect.objectContaining({
+ method: 'POST',
+ data: expect.objectContaining({
+ repositories: [],
+ automated_run_stopping_point: 'code_changes',
+ automation_handoff: undefined,
+ }),
+ })
+ );
+ });
+ });
+
+ it('sends correct API requests to all projects when integration is a CodingAgentIntegration', async () => {
+ const {projectPutRequests, seerPreferencesPostRequests} = setupMocks();
+ const integration: CodingAgentIntegration = {
+ id: '123',
+ name: 'Cursor',
+ provider: 'cursor',
+ };
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate(integration, {});
+ });
+
+ await waitFor(() => {
+ expect(projectPutRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((p, i) => {
+ expect(projectPutRequests[i]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${p.slug}/`,
+ expect.objectContaining({
+ method: 'PUT',
+ data: {autofixAutomationTuning: 'medium'},
+ })
+ );
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ expect.objectContaining({
+ method: 'POST',
+ data: expect.objectContaining({
+ automation_handoff: {
+ handoff_point: 'root_cause',
+ target: 'cursor_background_agent',
+ integration_id: 123,
+ auto_create_pr: false,
+ },
+ }),
+ })
+ );
+ });
+ });
+
+ it('sets auto_create_pr true when preference stopping point is open_pr', async () => {
+ const {seerPreferencesPostRequests} = setupMocks({
+ ...basePreference,
+ automated_run_stopping_point: 'open_pr',
+ });
+ const integration: CodingAgentIntegration = {
+ id: '456',
+ name: 'Cursor',
+ provider: 'cursor',
+ };
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate(integration, {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((_p, i) => {
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automation_handoff: expect.objectContaining({
+ auto_create_pr: true,
+ }),
+ }),
+ })
+ );
+ });
+ });
+
+ it('preserves repositories from each project preference', async () => {
+ const preferenceWithRepos: ProjectSeerPreferences = {
+ repositories: [
+ {external_id: 'repo-1', name: 'my-repo', owner: 'my-org', provider: 'github'},
+ ],
+ automated_run_stopping_point: 'code_changes',
+ automation_handoff: undefined,
+ };
+ const {seerPreferencesPostRequests} = setupMocks(preferenceWithRepos);
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate('seer', {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((_p, i) => {
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ repositories: [
+ {
+ external_id: 'repo-1',
+ name: 'my-repo',
+ owner: 'my-org',
+ provider: 'github',
+ },
+ ],
+ }),
+ })
+ );
+ });
+ });
+
+ it('updates ProjectsStore for all projects', async () => {
+ setupMocks();
+ const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess');
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate('seer', {});
+ });
+
+ await waitFor(() => {
+ expect(storeSpy).toHaveBeenCalledTimes(2);
+ });
+
+ projects.forEach(p => {
+ expect(storeSpy).toHaveBeenCalledWith({
+ id: p.id,
+ autofixAutomationTuning: 'medium',
+ });
+ });
+ });
+
+ it('updates ProjectsStore with "off" tuning for all projects when integration is "none"', async () => {
+ setupMocks();
+ const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess');
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate('none', {});
+ });
+
+ await waitFor(() => {
+ expect(storeSpy).toHaveBeenCalledTimes(2);
+ });
+
+ projects.forEach(p => {
+ expect(storeSpy).toHaveBeenCalledWith({
+ id: p.id,
+ autofixAutomationTuning: 'off',
+ });
+ });
+ });
+
+ it('calls onSuccess when all requests succeed', async () => {
+ setupMocks();
+ const onSuccess = jest.fn();
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate('seer', {onSuccess});
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('calls onError when any request fails', async () => {
+ projects.forEach(p => {
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {
+ preference: basePreference,
+ code_mapping_repos: [],
+ } satisfies SeerPreferencesResponse,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/`,
+ method: 'PUT',
+ statusCode: 500,
+ body: {},
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'POST',
+ body: {},
+ });
+ });
+ const onError = jest.fn();
+ const {result} = renderBulkMutateSelectedAgent();
+
+ act(() => {
+ result.current.mutate('seer', {onError});
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it('does nothing when projects list is empty', async () => {
+ const emptyProjectsMutate = renderHookWithProviders(
+ () => useBulkMutateSelectedAgent({projects: []}),
+ {organization}
+ );
+ const onSuccess = jest.fn();
+
+ act(() => {
+ emptyProjectsMutate.result.current('seer', {onSuccess});
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
describe('useMutateCreatePr', () => {
const basePreference: ProjectSeerPreferences = {
repositories: [],
diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx
index 0729f92255fec3..5626f25002bda3 100644
--- a/static/app/views/settings/seer/seerAgentHooks.tsx
+++ b/static/app/views/settings/seer/seerAgentHooks.tsx
@@ -4,6 +4,8 @@ import {
bulkAutofixAutomationSettingsInfiniteOptions,
type AutofixAutomationSettings,
} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
+import {makeProjectSeerPreferencesQueryKey} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences';
+import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences';
import {
useFetchProjectSeerPreferences,
useUpdateProjectSeerPreferences,
@@ -15,7 +17,7 @@ import {t} from 'sentry/locale';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import type {Project} from 'sentry/types/project';
import {useUpdateProject} from 'sentry/utils/project/useUpdateProject';
-import {useQueryClient} from 'sentry/utils/queryClient';
+import {fetchDataQuery, fetchMutation, useQueryClient} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
export function useAgentOptions({
@@ -32,6 +34,7 @@ export function useAgentOptions({
value: integration,
label: integration.name,
})),
+ {value: 'none' as const, label: t('No Handoff')},
];
}, [integrations]);
}
@@ -208,6 +211,82 @@ export function useMutateSelectedAgent({project}: {project: Project}) {
);
}
+export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) {
+ const organization = useOrganization();
+ const queryClient = useQueryClient();
+ const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({
+ organization,
+ });
+
+ return useCallback(
+ async (
+ integration: 'seer' | 'none' | CodingAgentIntegration,
+ {onSuccess, onError}: MutateOptions
+ ) => {
+ const tuning = integration === 'none' ? 'off' : 'medium';
+
+ try {
+ await Promise.all(
+ projects.map(async project => {
+ const [preferencesData] = await queryClient.fetchQuery({
+ queryKey: makeProjectSeerPreferencesQueryKey(
+ organization.slug,
+ project.slug
+ ),
+ queryFn: fetchDataQuery,
+ staleTime: 0,
+ });
+ const preference = preferencesData?.preference;
+
+ const handoff: ProjectSeerPreferences['automation_handoff'] =
+ integration !== 'seer' && integration !== 'none' && integration
+ ? {
+ handoff_point: 'root_cause',
+ target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!,
+ integration_id: Number(integration.id),
+ auto_create_pr:
+ preference?.automated_run_stopping_point === 'open_pr',
+ }
+ : undefined;
+
+ return Promise.all([
+ fetchMutation({
+ method: 'PUT',
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ data: {autofixAutomationTuning: tuning},
+ }),
+ fetchMutation({
+ method: 'POST',
+ url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
+ data: {
+ repositories: preference?.repositories ?? [],
+ automated_run_stopping_point: preference?.automated_run_stopping_point,
+ automation_handoff: handoff,
+ },
+ }),
+ ]);
+ })
+ );
+
+ projects.forEach(project => {
+ ProjectsStore.onUpdateSuccess({
+ id: project.id,
+ autofixAutomationTuning: tuning,
+ });
+ });
+ queryClient.invalidateQueries({
+ queryKey: autofixSettingsQueryOptions.queryKey,
+ });
+
+ onSuccess?.();
+ } catch {
+ onError?.(new Error('Failed to update agent setting'));
+ }
+ },
+ [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey]
+ );
+}
+
export function useMutateCreatePr({project}: {project: Project}) {
const {mutateAsync: updateProjectSeerPreferences} =
useUpdateProjectSeerPreferences(project);
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index f189c27c4f03dc..8aee364cc1ff57 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -46,7 +46,7 @@ export function SeerAutomationSettings() {
const organization = useOrganization();
const canWrite = useCanWriteSettings();
- const showSeerOverview = organization.features.includes('seer-overview');
+ const showSeerOverview = true; // organization.features.includes('seer-overview');
const scmOverviewData = useSCMOverviewSection();
const autofixOverviewData = useAutofixOverviewData();
From 743781be2fafc66ce7916358fbd8af0dc3d01695 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Mon, 30 Mar 2026 15:53:14 -0700
Subject: [PATCH 18/29] add bulk-edit of seer create-pr field
---
.../seer/overview/autofixOverviewSection.tsx | 34 +-
.../settings/seer/seerAgentHooks.spec.tsx | 331 ++++++++++++++++++
.../views/settings/seer/seerAgentHooks.tsx | 65 ++++
3 files changed, 416 insertions(+), 14 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index c98280084a5c5f..ad5e48815dd2e4 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -35,6 +35,7 @@ import {useOrganization} from 'sentry/utils/useOrganization';
import {useProjects} from 'sentry/utils/useProjects';
import {
useAgentOptions,
+ useBulkMutateCreatePr,
useBulkMutateSelectedAgent,
} from 'sentry/views/settings/seer/seerAgentHooks';
@@ -128,7 +129,7 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization}
isPending={isPending}
organization={organization}
projects={projects}
- projectsWithCreatePrCount={projectsWithCreatePr.length}
+ projectsWithCreatePr={projectsWithCreatePr}
/>
@@ -221,7 +222,7 @@ function AgentNameForm({
);
const rawAgentOptions = useAgentOptions({
integrations: integrations?.integrations ?? [],
- });
+ }).filter(option => option.value !== 'none');
const codingAgentOptions = rawAgentOptions.map(option => ({
value: option.value === 'seer' ? 'seer' : String(option.value.id),
label: option.label,
@@ -261,7 +262,7 @@ function AgentNameForm({
const preferredAgentIntegration =
preferredAgentValue === 'seer'
- ? ('seer' as const)
+ ? 'seer'
: (rawAgentOptions
.filter(option => option.value !== 'seer')
.find(option => option.value.id === preferredAgentValue)?.value ?? 'seer');
@@ -345,13 +346,13 @@ function CreatePrForm({
isPending,
organization,
projects,
- projectsWithCreatePrCount,
+ projectsWithCreatePr,
}: {
canWrite: boolean;
isPending: boolean;
organization: Organization;
projects: Project[];
- projectsWithCreatePrCount: number;
+ projectsWithCreatePr: AutofixAutomationSettings[];
}) {
const orgMutationOpts = mutationOptions({
mutationFn: (updateData: Partial) =>
@@ -363,6 +364,11 @@ function CreatePrForm({
onSuccess: updateOrganization,
});
+ const projectsWithCreatePrIds = new Set(projectsWithCreatePr.map(s => s.projectId));
+ const projectsToUpdate = projects.filter(p => !projectsWithCreatePrIds.has(p.id));
+
+ const bulkMutateCreatePr = useBulkMutateCreatePr({projects: projectsToUpdate});
+
return (
{
- // TODO
+ bulkMutateCreatePr(field.state.value, {});
}}
>
{field.state.value
? tn(
'Enable for the existing project',
'Enable for all existing projects',
- projectsWithCreatePrCount
+ projectsWithCreatePr.length
)
: tn(
'Disable for the existing project',
'Disable for all existing projects',
- projectsWithCreatePrCount
+ projectsWithCreatePr.length
)}
{projects.length === 0
? t('No projects found')
: projects.length === 1
- ? projectsWithCreatePrCount === 1
+ ? projectsWithCreatePr.length === 1
? t('Your existing project has Create PR enabled')
: t('Your existing project does not have Create PR enabled')
: field.state.value
- ? projects.length === projectsWithCreatePrCount
+ ? projects.length === projectsWithCreatePr.length
? t('All existing projects have Create PR enabled')
: t(
'%s of %s existing projects have Create PR enabled',
- projectsWithCreatePrCount,
+ projectsWithCreatePr.length,
projects.length
)
- : projects.length === projectsWithCreatePrCount
+ : projects.length === projectsWithCreatePr.length
? t('All existing projects have Create PR disabled')
: t(
'%s of %s existing projects have Create PR disabled',
- projectsWithCreatePrCount,
+ projectsWithCreatePr.length,
projects.length
)}
diff --git a/static/app/views/settings/seer/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx
index 7dae2df5a8fd02..bb72d9c17e7663 100644
--- a/static/app/views/settings/seer/seerAgentHooks.spec.tsx
+++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx
@@ -14,6 +14,7 @@ import {ProjectsStore} from 'sentry/stores/projectsStore';
import {useQueryClient} from 'sentry/utils/queryClient';
import {
useAgentOptions,
+ useBulkMutateCreatePr,
useBulkMutateSelectedAgent,
useMutateCreatePr,
useMutateSelectedAgent,
@@ -926,6 +927,336 @@ describe('seerAgentHooks', () => {
});
});
+ describe('useBulkMutateCreatePr', () => {
+ const project1 = ProjectFixture({slug: 'project-slug', id: '1'});
+ const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'});
+ const projects = [project1, project2];
+
+ const seerPreference: ProjectSeerPreferences = {
+ repositories: [],
+ automated_run_stopping_point: 'code_changes',
+ automation_handoff: undefined,
+ };
+
+ const integrationPreference: ProjectSeerPreferences = {
+ repositories: [],
+ automated_run_stopping_point: 'code_changes',
+ automation_handoff: {
+ handoff_point: 'root_cause',
+ target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT,
+ integration_id: 123,
+ auto_create_pr: false,
+ },
+ };
+
+ function setupMocks(
+ p1Preference: ProjectSeerPreferences = seerPreference,
+ p2Preference: ProjectSeerPreferences = seerPreference
+ ) {
+ const perProjectPrefs = [p1Preference, p2Preference];
+ const mocks = projects.map((p, i) => ({
+ seerPreferencesGetRequest: MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {
+ preference: perProjectPrefs[i],
+ code_mapping_repos: [],
+ } satisfies SeerPreferencesResponse,
+ }),
+ seerPreferencesPostRequest: MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'POST',
+ body: {},
+ }),
+ }));
+ return {
+ seerPreferencesGetRequests: mocks.map(m => m.seerPreferencesGetRequest),
+ seerPreferencesPostRequests: mocks.map(m => m.seerPreferencesPostRequest),
+ };
+ }
+
+ function renderBulkMutateCreatePr() {
+ return renderHookWithProviders(
+ (props: {projects: typeof projects}) => {
+ const mutate = useBulkMutateCreatePr(props);
+ return {mutate};
+ },
+ {
+ initialProps: {projects},
+ organization,
+ }
+ );
+ }
+
+ beforeEach(() => {
+ ProjectsStore.loadInitialData(projects);
+ });
+
+ it('sets automated_run_stopping_point for Seer projects when enabling Create PR', async () => {
+ const {seerPreferencesPostRequests} = setupMocks();
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(true, {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((p, i) => {
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ expect.objectContaining({
+ method: 'POST',
+ data: expect.objectContaining({
+ automated_run_stopping_point: 'open_pr',
+ automation_handoff: undefined,
+ }),
+ })
+ );
+ });
+ });
+
+ it('sets automated_run_stopping_point for Seer projects when disabling Create PR', async () => {
+ const {seerPreferencesPostRequests} = setupMocks(
+ {...seerPreference, automated_run_stopping_point: 'open_pr'},
+ {...seerPreference, automated_run_stopping_point: 'open_pr'}
+ );
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(false, {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((p, i) => {
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automated_run_stopping_point: 'code_changes',
+ }),
+ })
+ );
+ });
+ });
+
+ it('sets auto_create_pr in automation_handoff for external agent projects', async () => {
+ const {seerPreferencesPostRequests} = setupMocks(
+ integrationPreference,
+ integrationPreference
+ );
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(true, {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((p, i) => {
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automation_handoff: expect.objectContaining({
+ integration_id: 123,
+ auto_create_pr: true,
+ }),
+ }),
+ })
+ );
+ });
+ });
+
+ it('disables auto_create_pr in automation_handoff for external agent projects', async () => {
+ const {seerPreferencesPostRequests} = setupMocks(
+ {
+ ...integrationPreference,
+ automation_handoff: {
+ ...integrationPreference.automation_handoff!,
+ auto_create_pr: true,
+ },
+ },
+ {
+ ...integrationPreference,
+ automation_handoff: {
+ ...integrationPreference.automation_handoff!,
+ auto_create_pr: true,
+ },
+ }
+ );
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(false, {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((_p, i) => {
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automation_handoff: expect.objectContaining({
+ auto_create_pr: false,
+ }),
+ }),
+ })
+ );
+ });
+ });
+
+ it('handles mixed projects — Seer and external agent — correctly', async () => {
+ const {seerPreferencesPostRequests} = setupMocks(
+ seerPreference,
+ integrationPreference
+ );
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(true, {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ // project1 (Seer): uses stopping_point
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${project1.slug}/seer/preferences/`,
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automated_run_stopping_point: 'open_pr',
+ automation_handoff: undefined,
+ }),
+ })
+ );
+ // project2 (external agent): uses auto_create_pr in handoff
+ expect(seerPreferencesPostRequests[1]).toHaveBeenCalledWith(
+ `/projects/${organization.slug}/${project2.slug}/seer/preferences/`,
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automation_handoff: expect.objectContaining({
+ integration_id: 123,
+ auto_create_pr: true,
+ }),
+ }),
+ })
+ );
+ });
+
+ it('preserves existing repositories and other handoff fields', async () => {
+ const preferenceWithRepos: ProjectSeerPreferences = {
+ repositories: [
+ {external_id: 'repo-1', name: 'my-repo', owner: 'my-org', provider: 'github'},
+ ],
+ automated_run_stopping_point: 'code_changes',
+ automation_handoff: undefined,
+ };
+ const {seerPreferencesPostRequests} = setupMocks(
+ preferenceWithRepos,
+ preferenceWithRepos
+ );
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(true, {});
+ });
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1);
+ });
+
+ projects.forEach((_p, i) => {
+ expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ repositories: [
+ {
+ external_id: 'repo-1',
+ name: 'my-repo',
+ owner: 'my-org',
+ provider: 'github',
+ },
+ ],
+ }),
+ })
+ );
+ });
+ });
+
+ it('calls onSuccess when all requests succeed', async () => {
+ setupMocks();
+ const onSuccess = jest.fn();
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(true, {onSuccess});
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('calls onError when any request fails', async () => {
+ projects.forEach(p => {
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {
+ preference: seerPreference,
+ code_mapping_repos: [],
+ } satisfies SeerPreferencesResponse,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`,
+ method: 'POST',
+ statusCode: 500,
+ body: {},
+ });
+ });
+ const onError = jest.fn();
+ const {result} = renderBulkMutateCreatePr();
+
+ act(() => {
+ result.current.mutate(true, {onError});
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it('does nothing when projects list is empty', async () => {
+ const emptyProjectsMutate = renderHookWithProviders(
+ () => useBulkMutateCreatePr({projects: []}),
+ {organization}
+ );
+ const onSuccess = jest.fn();
+
+ act(() => {
+ emptyProjectsMutate.result.current(true, {onSuccess});
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
describe('useMutateCreatePr', () => {
const basePreference: ProjectSeerPreferences = {
repositories: [],
diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx
index 5626f25002bda3..e6819fa41253d0 100644
--- a/static/app/views/settings/seer/seerAgentHooks.tsx
+++ b/static/app/views/settings/seer/seerAgentHooks.tsx
@@ -287,6 +287,71 @@ export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) {
);
}
+export function useBulkMutateCreatePr({projects}: {projects: Project[]}) {
+ const organization = useOrganization();
+ const queryClient = useQueryClient();
+ const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({
+ organization,
+ });
+
+ return useCallback(
+ async (value: boolean, {onSuccess, onError}: MutateOptions) => {
+ try {
+ await Promise.all(
+ projects.map(async project => {
+ const [preferencesData] = await queryClient.fetchQuery({
+ queryKey: makeProjectSeerPreferencesQueryKey(
+ organization.slug,
+ project.slug
+ ),
+ queryFn: fetchDataQuery,
+ staleTime: 0,
+ });
+ const preference = preferencesData?.preference;
+
+ if (preference?.automation_handoff?.integration_id) {
+ const updatedHandoff = {
+ ...preference.automation_handoff,
+ auto_create_pr: value,
+ };
+ return fetchMutation({
+ method: 'POST',
+ url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
+ data: {
+ repositories: preference?.repositories ?? [],
+ automated_run_stopping_point: preference?.automated_run_stopping_point,
+ automation_handoff: updatedHandoff,
+ },
+ });
+ }
+
+ const stoppingPoint = value
+ ? ('open_pr' as const)
+ : ('code_changes' as const);
+ return fetchMutation({
+ method: 'POST',
+ url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
+ data: {
+ repositories: preference?.repositories ?? [],
+ automated_run_stopping_point: stoppingPoint,
+ automation_handoff: preference?.automation_handoff,
+ },
+ });
+ })
+ );
+
+ queryClient.invalidateQueries({
+ queryKey: autofixSettingsQueryOptions.queryKey,
+ });
+ onSuccess?.();
+ } catch {
+ onError?.(new Error('Failed to update PR setting'));
+ }
+ },
+ [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey]
+ );
+}
+
export function useMutateCreatePr({project}: {project: Project}) {
const {mutateAsync: updateProjectSeerPreferences} =
useUpdateProjectSeerPreferences(project);
From d6a7389039d7db886a50be6a312da95d7a3381a4 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Mon, 30 Mar 2026 15:56:54 -0700
Subject: [PATCH 19/29] add busy state to the bulk edit buttons
---
.../seer/overview/autofixOverviewSection.tsx | 24 +++++++++++++------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index ad5e48815dd2e4..bb265ed584a342 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -1,3 +1,4 @@
+import {useState} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import {mutationOptions} from '@tanstack/react-query';
@@ -275,6 +276,7 @@ function AgentNameForm({
const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({
projects: projectsToUpdate,
});
+ const [isBulkMutatingAgent, setIsBulkMutatingAgent] = useState(false);
return (
{
- bulkMutateSelectedAgent(preferredAgentIntegration, {});
+ onClick={async () => {
+ setIsBulkMutatingAgent(true);
+ await bulkMutateSelectedAgent(preferredAgentIntegration, {});
+ setIsBulkMutatingAgent(false);
}}
>
{tn(
@@ -368,6 +374,7 @@ function CreatePrForm({
const projectsToUpdate = projects.filter(p => !projectsWithCreatePrIds.has(p.id));
const bulkMutateCreatePr = useBulkMutateCreatePr({projects: projectsToUpdate});
+ const [isBulkMutatingCreatePr, setIsBulkMutatingCreatePr] = useState(false);
return (
{
- bulkMutateCreatePr(field.state.value, {});
+ onClick={async () => {
+ setIsBulkMutatingCreatePr(true);
+ await bulkMutateCreatePr(field.state.value, {});
+ setIsBulkMutatingCreatePr(false);
}}
>
{field.state.value
From bb791b3929653c662a0e6b482e4240f51ca9e60f Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Mon, 30 Mar 2026 16:07:32 -0700
Subject: [PATCH 20/29] catch 429s and throw an error to the user. This happens
because we are doing a lot of extra requests to overcome shortcomings in the
api design. the updated api is coming online soon.
---
.../views/settings/seer/seerAgentHooks.tsx | 196 ++++++++++--------
1 file changed, 108 insertions(+), 88 deletions(-)
diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx
index e6819fa41253d0..ae261b5807e869 100644
--- a/static/app/views/settings/seer/seerAgentHooks.tsx
+++ b/static/app/views/settings/seer/seerAgentHooks.tsx
@@ -1,5 +1,6 @@
import {useCallback, useMemo} from 'react';
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {
bulkAutofixAutomationSettingsInfiniteOptions,
type AutofixAutomationSettings,
@@ -18,6 +19,7 @@ import {ProjectsStore} from 'sentry/stores/projectsStore';
import type {Project} from 'sentry/types/project';
import {useUpdateProject} from 'sentry/utils/project/useUpdateProject';
import {fetchDataQuery, fetchMutation, useQueryClient} from 'sentry/utils/queryClient';
+import {RequestError} from 'sentry/utils/requestError/requestError';
import {useOrganization} from 'sentry/utils/useOrganization';
export function useAgentOptions({
@@ -225,62 +227,73 @@ export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) {
) => {
const tuning = integration === 'none' ? 'off' : 'medium';
- try {
- await Promise.all(
- projects.map(async project => {
- const [preferencesData] = await queryClient.fetchQuery({
- queryKey: makeProjectSeerPreferencesQueryKey(
- organization.slug,
- project.slug
- ),
- queryFn: fetchDataQuery,
- staleTime: 0,
- });
- const preference = preferencesData?.preference;
+ const results = await Promise.allSettled(
+ projects.map(async project => {
+ const [preferencesData] = await queryClient.fetchQuery({
+ queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
+ queryFn: fetchDataQuery,
+ staleTime: 0,
+ });
+ const preference = preferencesData?.preference;
- const handoff: ProjectSeerPreferences['automation_handoff'] =
- integration !== 'seer' && integration !== 'none' && integration
- ? {
- handoff_point: 'root_cause',
- target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!,
- integration_id: Number(integration.id),
- auto_create_pr:
- preference?.automated_run_stopping_point === 'open_pr',
- }
- : undefined;
+ const handoff: ProjectSeerPreferences['automation_handoff'] =
+ integration !== 'seer' && integration !== 'none' && integration
+ ? {
+ handoff_point: 'root_cause',
+ target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!,
+ integration_id: Number(integration.id),
+ auto_create_pr: preference?.automated_run_stopping_point === 'open_pr',
+ }
+ : undefined;
- return Promise.all([
- fetchMutation({
- method: 'PUT',
- url: `/projects/${organization.slug}/${project.slug}/`,
- data: {autofixAutomationTuning: tuning},
- }),
- fetchMutation({
- method: 'POST',
- url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
- data: {
- repositories: preference?.repositories ?? [],
- automated_run_stopping_point: preference?.automated_run_stopping_point,
- automation_handoff: handoff,
- },
- }),
- ]);
- })
- );
+ return Promise.all([
+ fetchMutation({
+ method: 'PUT',
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ data: {autofixAutomationTuning: tuning},
+ }),
+ fetchMutation({
+ method: 'POST',
+ url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
+ data: {
+ repositories: preference?.repositories ?? [],
+ automated_run_stopping_point: preference?.automated_run_stopping_point,
+ automation_handoff: handoff,
+ },
+ }),
+ ]);
+ })
+ );
- projects.forEach(project => {
+ // Update store only for projects that succeeded
+ results.forEach((result, i) => {
+ if (result.status === 'fulfilled') {
ProjectsStore.onUpdateSuccess({
- id: project.id,
+ id: projects[i]!.id,
autofixAutomationTuning: tuning,
});
- });
- queryClient.invalidateQueries({
- queryKey: autofixSettingsQueryOptions.queryKey,
- });
+ }
+ });
+ // Always invalidate to sync cache with whatever the server actually saved
+ queryClient.invalidateQueries({
+ queryKey: autofixSettingsQueryOptions.queryKey,
+ });
+
+ const failures = results.filter(r => r.status === 'rejected');
+ if (failures.length === 0) {
onSuccess?.();
- } catch {
- onError?.(new Error('Failed to update agent setting'));
+ } else {
+ const has429 = failures.some(
+ r => r.reason instanceof RequestError && r.reason.status === 429
+ );
+ if (has429) {
+ addErrorMessage(
+ t('Too many requests. Please wait a moment before trying again.')
+ );
+ } else {
+ onError?.(new Error('Failed to update agent setting'));
+ }
}
},
[projects, organization, queryClient, autofixSettingsQueryOptions.queryKey]
@@ -296,56 +309,63 @@ export function useBulkMutateCreatePr({projects}: {projects: Project[]}) {
return useCallback(
async (value: boolean, {onSuccess, onError}: MutateOptions) => {
- try {
- await Promise.all(
- projects.map(async project => {
- const [preferencesData] = await queryClient.fetchQuery({
- queryKey: makeProjectSeerPreferencesQueryKey(
- organization.slug,
- project.slug
- ),
- queryFn: fetchDataQuery,
- staleTime: 0,
- });
- const preference = preferencesData?.preference;
-
- if (preference?.automation_handoff?.integration_id) {
- const updatedHandoff = {
- ...preference.automation_handoff,
- auto_create_pr: value,
- };
- return fetchMutation({
- method: 'POST',
- url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
- data: {
- repositories: preference?.repositories ?? [],
- automated_run_stopping_point: preference?.automated_run_stopping_point,
- automation_handoff: updatedHandoff,
- },
- });
- }
+ const results = await Promise.allSettled(
+ projects.map(async project => {
+ const [preferencesData] = await queryClient.fetchQuery({
+ queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
+ queryFn: fetchDataQuery,
+ staleTime: 0,
+ });
+ const preference = preferencesData?.preference;
- const stoppingPoint = value
- ? ('open_pr' as const)
- : ('code_changes' as const);
+ if (preference?.automation_handoff?.integration_id) {
+ const updatedHandoff = {
+ ...preference.automation_handoff,
+ auto_create_pr: value,
+ };
return fetchMutation({
method: 'POST',
url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
data: {
repositories: preference?.repositories ?? [],
- automated_run_stopping_point: stoppingPoint,
- automation_handoff: preference?.automation_handoff,
+ automated_run_stopping_point: preference?.automated_run_stopping_point,
+ automation_handoff: updatedHandoff,
},
});
- })
- );
+ }
- queryClient.invalidateQueries({
- queryKey: autofixSettingsQueryOptions.queryKey,
- });
+ const stoppingPoint = value ? ('open_pr' as const) : ('code_changes' as const);
+ return fetchMutation({
+ method: 'POST',
+ url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
+ data: {
+ repositories: preference?.repositories ?? [],
+ automated_run_stopping_point: stoppingPoint,
+ automation_handoff: preference?.automation_handoff,
+ },
+ });
+ })
+ );
+
+ // Always invalidate to sync cache with whatever the server actually saved
+ queryClient.invalidateQueries({
+ queryKey: autofixSettingsQueryOptions.queryKey,
+ });
+
+ const failures = results.filter(r => r.status === 'rejected');
+ if (failures.length === 0) {
onSuccess?.();
- } catch {
- onError?.(new Error('Failed to update PR setting'));
+ } else {
+ const has429 = failures.some(
+ r => r.reason instanceof RequestError && r.reason.status === 429
+ );
+ if (has429) {
+ addErrorMessage(
+ t('Too many requests. Please wait a moment before trying again.')
+ );
+ } else {
+ onError?.(new Error('Failed to update PR setting'));
+ }
}
},
[projects, organization, queryClient, autofixSettingsQueryOptions.queryKey]
From 54fa76259788b507cf59b92d6b829712a25a6ca3 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 08:43:16 -0700
Subject: [PATCH 21/29] rm connectedrepo and stopping point forms for now
---
.../seer/overview/autofixOverviewSection.tsx | 304 +-----------------
1 file changed, 2 insertions(+), 302 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index bb265ed584a342..eefe8bf1c7659f 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -1,30 +1,20 @@
import {useState} from 'react';
-import {css} from '@emotion/react';
-import styled from '@emotion/styled';
import {mutationOptions} from '@tanstack/react-query';
import {z} from 'zod';
import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
-import {
- AutoSaveForm,
- defaultFormOptions,
- FieldGroup,
- useScrapsForm,
-} from '@sentry/scraps/form';
+import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
-import {Radio} from '@sentry/scraps/radio';
import {Text} from '@sentry/scraps/text';
-import {openModal} from 'sentry/actionCreators/modal';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {
bulkAutofixAutomationSettingsInfiniteOptions,
type AutofixAutomationSettings,
} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings';
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
-import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal';
import {IconSettings} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
@@ -89,13 +79,7 @@ type Props = ReturnType & {
export function AutofixOverviewSection({canWrite, data, isPending, organization}: Props) {
const {projects} = useProjects();
- const {
- projectsWithRepos = [],
- projectsWithPreferredAgent = [],
- projectsWithCreatePr = [],
- } = data ?? {};
-
- const projectsWithReposCount = projectsWithRepos.length;
+ const {projectsWithPreferredAgent = [], projectsWithCreatePr = []} = data ?? {};
return (
}
>
-
-
-
-
);
}
-function ConnectedReposForm({
- projects,
- projectsWithReposCount,
-}: {
- projects: Project[];
- projectsWithReposCount: number;
-}) {
- const form = useScrapsForm(defaultFormOptions);
-
- return (
-
-
- {field => (
-
-
-
- {
- openModal(
- deps => (
-
- ),
- {
- modalCss: css`
- width: 700px;
- `,
- // onClose: refetchIntegrations,
- }
- );
- }}
- >
- {t('Connect Projects and Repos')}
-
-
-
-
-
-
- {projects.length === 0
- ? t('No projects found')
- : projects.length === 1
- ? projectsWithReposCount === 1
- ? t('Your existing project has repos connected')
- : t('Your existing project does not have any repos connected')
- : projects.length === projectsWithReposCount
- ? t('All existing projects have repos connected')
- : t(
- '%s of %s existing projects have repos connected',
- projectsWithReposCount,
- projects.length
- )}
-
-
-
- )}
-
-
- );
-}
-
function AgentNameForm({
canWrite,
isPending,
@@ -478,213 +388,3 @@ function CreatePrForm({
);
}
-
-function StoppingPointForm({organization}: {organization: Organization}) {
- const stoppingPointMutationOpts = mutationOptions({
- mutationFn: ({
- stoppingPoint,
- }: {
- stoppingPoint:
- | 'off'
- | Organization['defaultAutomatedRunStoppingPoint'][keyof Organization['defaultAutomatedRunStoppingPoint']];
- }) => {
- return fetchMutation({
- method: 'PUT',
- url: `/organizations/${organization.slug}/`,
- data:
- stoppingPoint === 'off'
- ? {
- defaultAutofixAutomationTuning: 'off',
- defaultAutomatedRunStoppingPoint: null,
- }
- : {
- defaultAutofixAutomationTuning: 'medium',
- defaultAutomatedRunStoppingPoint: stoppingPoint,
- },
- });
- },
- onSuccess: updateOrganization,
- });
-
- const initialValue =
- organization.defaultAutofixAutomationTuning === 'off'
- ? 'off'
- : (organization.defaultAutomatedRunStoppingPoint ?? 'off');
-
- return (
-
- {field => (
-
-
- ),
- }
- )}
- >
-
- {/* {false && (
-
- )}
- */}
-
- >
- {(baseProps, {indicator}) => (
-
-
-
- field.handleChange('off')}
- />
-
- {t('No Automation')}
-
-
-
- field.handleChange('root_cause')}
- />
-
- {t('Root Cause Analysis')}
-
-
-
-
- field.handleChange('code_changes')}
- />
-
- {t('Propose Code Changes')}
-
-
-
- {indicator ?? }
-
- )}
-
-
- {/* */}
-
-
-
- )}
-
- );
-}
-
-const StoppingPointContainer = styled(Flex, {
- shouldForwardProp: prop => prop !== 'selectedValue',
-})<{selectedValue: string}>`
- position: relative;
- isolation: isolate;
-
- &::before {
- content: '';
- position: absolute;
- /* Vertically centered through the radio buttons (24px default diameter → 12px center) */
- top: 12px;
- transform: translateY(-50%);
- height: 1em;
-
- /*
- * Left is fixed at the center of the first item.
- * Width expands to reach the center of whichever item is selected,
- * animating left to right. Each label has flex:1 so all three are equal
- * width W = (100% - 2*gap) / 3. Distance from center of item 1 to:
- * item 2: W + gap = (100% + gap) / 3
- * item 3: 2W + 2*gap = (200% + 2*gap) / 3
- */
- left: calc((100% - ${p => p.theme.space.md} * 2) / 6);
- width: ${p => {
- const gap = p.theme.space.md;
- switch (p.selectedValue) {
- case 'root_cause':
- return `calc((100% + ${gap}) / 3)`;
- case 'code_changes':
- case 'open_pr':
- return `calc((200% + ${gap} * 2) / 3)`;
- default: // 'off'
- return '0px';
- }
- }};
-
- background: ${p => p.theme.colors.blue400};
- pointer-events: none;
- z-index: -1;
- transition: width ${p => p.theme.motion.smooth.moderate};
- }
-`;
-
-const StoppingPointLabel = styled(Stack)`
- flex: 1;
- & input {
- opacity: 1 !important;
- background: ${p => p.theme.tokens.background.primary};
- }
-`;
From 0785ab72f130224b3e2b9e3640aa76c4cae12bcd Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:01:01 -0700
Subject: [PATCH 22/29] do chunking to avoid 429s a bit more
---
static/app/utils/array/procesInChunks.ts | 19 +++++
.../views/settings/seer/seerAgentHooks.tsx | 71 +++++++++----------
2 files changed, 52 insertions(+), 38 deletions(-)
create mode 100644 static/app/utils/array/procesInChunks.ts
diff --git a/static/app/utils/array/procesInChunks.ts b/static/app/utils/array/procesInChunks.ts
new file mode 100644
index 00000000000000..42d94f72f55d8f
--- /dev/null
+++ b/static/app/utils/array/procesInChunks.ts
@@ -0,0 +1,19 @@
+interface Props {
+ chunkSize: number;
+ fn: (item: T) => Promise;
+ items: T[];
+}
+
+export async function processInChunks({
+ items,
+ chunkSize,
+ fn,
+}: Props): Promise>> {
+ const results: Array> = [];
+ for (let i = 0; i < items.length; i += chunkSize) {
+ const chunk = items.slice(i, i + chunkSize);
+ const chunkResults = await Promise.allSettled(chunk.map(fn));
+ results.push(...chunkResults);
+ }
+ return results;
+}
diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx
index ae261b5807e869..bd76972c47b505 100644
--- a/static/app/views/settings/seer/seerAgentHooks.tsx
+++ b/static/app/views/settings/seer/seerAgentHooks.tsx
@@ -17,6 +17,7 @@ import {type CodingAgentIntegration} from 'sentry/components/events/autofix/useA
import {t} from 'sentry/locale';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import type {Project} from 'sentry/types/project';
+import {processInChunks} from 'sentry/utils/array/procesInChunks';
import {useUpdateProject} from 'sentry/utils/project/useUpdateProject';
import {fetchDataQuery, fetchMutation, useQueryClient} from 'sentry/utils/queryClient';
import {RequestError} from 'sentry/utils/requestError/requestError';
@@ -99,11 +100,6 @@ export function useSelectedAgentFromBulkSettings({
]);
}
-type MutateOptions = {
- onError?: (error: Error) => void;
- onSuccess?: () => void;
-};
-
function useApplyOptimisticUpdate({project}: {project: Project}) {
const queryClient = useQueryClient();
const organization = useOrganization();
@@ -144,6 +140,11 @@ function useApplyOptimisticUpdate({project}: {project: Project}) {
);
}
+type MutateOptions = {
+ onError?: (error: Error) => void;
+ onSuccess?: () => void;
+};
+
export function useMutateSelectedAgent({project}: {project: Project}) {
const {mutateAsync: updateProject} = useUpdateProject(project);
const {mutateAsync: updateProjectSeerPreferences} =
@@ -225,10 +226,10 @@ export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) {
integration: 'seer' | 'none' | CodingAgentIntegration,
{onSuccess, onError}: MutateOptions
) => {
- const tuning = integration === 'none' ? 'off' : 'medium';
-
- const results = await Promise.allSettled(
- projects.map(async project => {
+ const results = await processInChunks({
+ items: projects,
+ chunkSize: 10,
+ fn: async project => {
const [preferencesData] = await queryClient.fetchQuery({
queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
queryFn: fetchDataQuery,
@@ -250,7 +251,7 @@ export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) {
fetchMutation({
method: 'PUT',
url: `/projects/${organization.slug}/${project.slug}/`,
- data: {autofixAutomationTuning: tuning},
+ data: {autofixAutomationTuning: integration === 'none' ? 'off' : 'medium'},
}),
fetchMutation({
method: 'POST',
@@ -262,8 +263,8 @@ export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) {
},
}),
]);
- })
- );
+ },
+ });
// Update store only for projects that succeeded
results.forEach((result, i) => {
@@ -309,8 +310,10 @@ export function useBulkMutateCreatePr({projects}: {projects: Project[]}) {
return useCallback(
async (value: boolean, {onSuccess, onError}: MutateOptions) => {
- const results = await Promise.allSettled(
- projects.map(async project => {
+ const results = await processInChunks({
+ items: projects,
+ chunkSize: 10,
+ fn: async project => {
const [preferencesData] = await queryClient.fetchQuery({
queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
queryFn: fetchDataQuery,
@@ -318,34 +321,26 @@ export function useBulkMutateCreatePr({projects}: {projects: Project[]}) {
});
const preference = preferencesData?.preference;
- if (preference?.automation_handoff?.integration_id) {
- const updatedHandoff = {
- ...preference.automation_handoff,
- auto_create_pr: value,
- };
- return fetchMutation({
- method: 'POST',
- url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
- data: {
- repositories: preference?.repositories ?? [],
- automated_run_stopping_point: preference?.automated_run_stopping_point,
- automation_handoff: updatedHandoff,
- },
- });
- }
-
- const stoppingPoint = value ? ('open_pr' as const) : ('code_changes' as const);
return fetchMutation({
method: 'POST',
url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
- data: {
- repositories: preference?.repositories ?? [],
- automated_run_stopping_point: stoppingPoint,
- automation_handoff: preference?.automation_handoff,
- },
+ data: preference?.automation_handoff?.integration_id
+ ? {
+ repositories: preference?.repositories ?? [],
+ automated_run_stopping_point: preference?.automated_run_stopping_point,
+ automation_handoff: {
+ ...preference.automation_handoff,
+ auto_create_pr: value,
+ },
+ }
+ : {
+ repositories: preference?.repositories ?? [],
+ automated_run_stopping_point: value ? 'open_pr' : 'code_changes',
+ automation_handoff: preference?.automation_handoff,
+ },
});
- })
- );
+ },
+ });
// Always invalidate to sync cache with whatever the server actually saved
queryClient.invalidateQueries({
From 0aa772b1c72ee7379220c1a76acdfebd8cd0b0c3 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:06:51 -0700
Subject: [PATCH 23/29] disable buttons while requests are in-flight
---
.../seer/overview/autofixOverviewSection.tsx | 25 +++++++++++++++++--
.../views/settings/seer/seerAgentHooks.tsx | 4 +--
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index eefe8bf1c7659f..9a5f51f0bdf06d 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -81,6 +81,9 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization}
const {projectsWithPreferredAgent = [], projectsWithCreatePr = []} = data ?? {};
+ const [isBulkMutatingAgent, setIsBulkMutatingAgent] = useState(false);
+ const [isBulkMutatingCreatePr, setIsBulkMutatingCreatePr] = useState(false);
+
return (
void;
}) {
const {data: integrations} = useQuery(
organizationIntegrationsCodingAgents(organization)
@@ -186,7 +201,6 @@ function AgentNameForm({
const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({
projects: projectsToUpdate,
});
- const [isBulkMutatingAgent, setIsBulkMutatingAgent] = useState(false);
return (
{
@@ -260,15 +275,21 @@ function AgentNameForm({
function CreatePrForm({
canWrite,
isPending,
+ isBulkMutatingCreatePr,
+ setIsBulkMutatingCreatePr,
+ isBulkMutatingAgent,
organization,
projects,
projectsWithCreatePr,
}: {
canWrite: boolean;
+ isBulkMutatingAgent: boolean;
+ isBulkMutatingCreatePr: boolean;
isPending: boolean;
organization: Organization;
projects: Project[];
projectsWithCreatePr: AutofixAutomationSettings[];
+ setIsBulkMutatingCreatePr: (value: boolean) => void;
}) {
const orgMutationOpts = mutationOptions({
mutationFn: (updateData: Partial) =>
@@ -284,7 +305,6 @@ function CreatePrForm({
const projectsToUpdate = projects.filter(p => !projectsWithCreatePrIds.has(p.id));
const bulkMutateCreatePr = useBulkMutateCreatePr({projects: projectsToUpdate});
- const [isBulkMutatingCreatePr, setIsBulkMutatingCreatePr] = useState(false);
return (
{
const results = await processInChunks({
items: projects,
- chunkSize: 10,
+ chunkSize: 15,
fn: async project => {
const [preferencesData] = await queryClient.fetchQuery({
queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
@@ -271,7 +271,7 @@ export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) {
if (result.status === 'fulfilled') {
ProjectsStore.onUpdateSuccess({
id: projects[i]!.id,
- autofixAutomationTuning: tuning,
+ autofixAutomationTuning: integration === 'none' ? 'off' : 'medium',
});
}
});
From d5b47148c77df127dac449676f84934c8ae28388 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:11:20 -0700
Subject: [PATCH 24/29] add tests for processInChunks
---
static/app/utils/array/procesInChunks.spec.ts | 108 ++++++++++++++++++
static/app/utils/array/procesInChunks.ts | 12 +-
2 files changed, 114 insertions(+), 6 deletions(-)
create mode 100644 static/app/utils/array/procesInChunks.spec.ts
diff --git a/static/app/utils/array/procesInChunks.spec.ts b/static/app/utils/array/procesInChunks.spec.ts
new file mode 100644
index 00000000000000..80a4618ae083b0
--- /dev/null
+++ b/static/app/utils/array/procesInChunks.spec.ts
@@ -0,0 +1,108 @@
+import {processInChunks} from 'sentry/utils/array/procesInChunks';
+
+describe('processInChunks', () => {
+ it('returns an empty array for empty input', async () => {
+ const fn = jest.fn().mockResolvedValue('x');
+ const results = await processInChunks({items: [], chunkSize: 3, fn});
+ expect(results).toEqual([]);
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ it('processes all items when count is less than chunkSize', async () => {
+ const fn = jest.fn((x: number) => Promise.resolve(x * 2));
+ const results = await processInChunks({items: [1, 2], chunkSize: 5, fn});
+ expect(results).toEqual([
+ {status: 'fulfilled', value: 2},
+ {status: 'fulfilled', value: 4},
+ ]);
+ });
+
+ it('processes all items when count equals chunkSize exactly', async () => {
+ const fn = jest.fn((x: number) => Promise.resolve(x * 2));
+ const results = await processInChunks({items: [1, 2, 3], chunkSize: 3, fn});
+ expect(results).toHaveLength(3);
+ expect(results.every(r => r.status === 'fulfilled')).toBe(true);
+ });
+
+ it('processes all items across multiple chunks', async () => {
+ const fn = jest.fn((x: number) => Promise.resolve(x));
+ const results = await processInChunks({
+ items: [1, 2, 3, 4, 5],
+ chunkSize: 2,
+ fn,
+ });
+ expect(results).toHaveLength(5);
+ expect(fn).toHaveBeenCalledTimes(5);
+ expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([
+ 1, 2, 3, 4, 5,
+ ]);
+ });
+
+ it('preserves result order matching input order', async () => {
+ // Simulate varying async latency: later items resolve faster
+ const fn = jest.fn(
+ (x: number) =>
+ new Promise(resolve => setTimeout(() => resolve(x), (10 - x) * 10))
+ );
+ const results = await processInChunks({items: [1, 2, 3, 4, 5], chunkSize: 5, fn});
+ expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([
+ 1, 2, 3, 4, 5,
+ ]);
+ });
+
+ it('processes chunks sequentially, not all at once', async () => {
+ const callOrder: number[] = [];
+ const fn = jest.fn((x: number) => {
+ callOrder.push(x);
+ return Promise.resolve(x);
+ });
+
+ await processInChunks({items: [1, 2, 3, 4, 5, 6], chunkSize: 2, fn});
+
+ // Each chunk of 2 must start only after the previous chunk completes.
+ // Because fn is synchronous here, within each chunk the call order is
+ // preserved and chunks are processed sequentially.
+ expect(callOrder).toEqual([1, 2, 3, 4, 5, 6]);
+ });
+
+ it('marks rejected items as rejected without stopping other items', async () => {
+ const fn = jest.fn((x: number) =>
+ x === 3 ? Promise.reject(new Error('boom')) : Promise.resolve(x)
+ );
+ const results = await processInChunks({items: [1, 2, 3, 4, 5], chunkSize: 5, fn});
+ expect(results).toHaveLength(5);
+ expect(results[0]).toEqual({status: 'fulfilled', value: 1});
+ expect(results[1]).toEqual({status: 'fulfilled', value: 2});
+ expect(results[2]).toMatchObject({status: 'rejected', reason: expect.any(Error)});
+ expect(results[3]).toEqual({status: 'fulfilled', value: 4});
+ expect(results[4]).toEqual({status: 'fulfilled', value: 5});
+ });
+
+ it('continues processing later chunks when an earlier chunk has failures', async () => {
+ const fn = jest.fn((x: number) =>
+ x === 1 ? Promise.reject(new Error('first chunk error')) : Promise.resolve(x)
+ );
+ // chunk 1: [1] (fails), chunk 2: [2, 3] (succeeds)
+ const results = await processInChunks({items: [1, 2, 3], chunkSize: 1, fn});
+ expect(results).toHaveLength(3);
+ expect(results[0]).toMatchObject({status: 'rejected'});
+ expect(results[1]).toEqual({status: 'fulfilled', value: 2});
+ expect(results[2]).toEqual({status: 'fulfilled', value: 3});
+ });
+
+ it('handles chunkSize of 1 by processing items one at a time', async () => {
+ const fn = jest.fn((x: number) => Promise.resolve(x));
+ const results = await processInChunks({items: [10, 20, 30], chunkSize: 1, fn});
+ expect(results).toHaveLength(3);
+ expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([
+ 10, 20, 30,
+ ]);
+ });
+
+ it('handles chunkSize larger than item count', async () => {
+ const fn = jest.fn((x: number) => Promise.resolve(x));
+ const results = await processInChunks({items: [1, 2], chunkSize: 100, fn});
+ expect(results).toHaveLength(2);
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/static/app/utils/array/procesInChunks.ts b/static/app/utils/array/procesInChunks.ts
index 42d94f72f55d8f..5bb4458b2598e1 100644
--- a/static/app/utils/array/procesInChunks.ts
+++ b/static/app/utils/array/procesInChunks.ts
@@ -1,15 +1,15 @@
-interface Props {
+interface Props- {
chunkSize: number;
- fn: (item: T) => Promise
;
- items: T[];
+ fn: (item: Item) => Promise;
+ items: Item[];
}
-export async function processInChunks({
+export async function processInChunks- ({
items,
chunkSize,
fn,
-}: Props
): Promise>> {
- const results: Array> = [];
+}: Props- ): Promise
>> {
+ const results: Array> = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const chunkResults = await Promise.allSettled(chunk.map(fn));
From f393df4d4b879a634ca4c8401228297dfcabbf63 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:13:28 -0700
Subject: [PATCH 25/29] rm defaultAutomatedRunStoppingPoint field for now
---
static/app/types/organization.tsx | 2 --
tests/js/fixtures/organization.ts | 1 -
2 files changed, 3 deletions(-)
diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx
index 84398e0599a8c3..1123f243ba52c5 100644
--- a/static/app/types/organization.tsx
+++ b/static/app/types/organization.tsx
@@ -1,4 +1,3 @@
-import type {AutofixStoppingPoint} from 'sentry/components/events/autofix/types';
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
import type {
DatasetSource,
@@ -65,7 +64,6 @@ export interface Organization extends OrganizationSummary {
dataScrubber: boolean;
dataScrubberDefaults: boolean;
debugFilesRole: string;
- defaultAutomatedRunStoppingPoint: 'off' | AutofixStoppingPoint;
defaultCodeReviewTriggers: CodeReviewTrigger[];
defaultCodingAgent: string | null;
defaultCodingAgentIntegrationId: string | number | null;
diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts
index 3ca79e22c10199..ba32d2dac42785 100644
--- a/tests/js/fixtures/organization.ts
+++ b/tests/js/fixtures/organization.ts
@@ -56,7 +56,6 @@ export function OrganizationFixture(params: Partial = {}): Organiz
debugFilesRole: '',
defaultCodeReviewTriggers: [],
defaultCodingAgentIntegrationId: null,
- defaultAutomatedRunStoppingPoint: 'off',
defaultCodingAgent: 'seer',
defaultRole: '',
enhancedPrivacy: false,
From 657bb6c30f7002c96f566fbe85c6c35f68a1fa57 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:14:48 -0700
Subject: [PATCH 26/29] rm extra changes
---
.../seer/overview/codeReviewOverviewSection.tsx | 13 +++++++------
static/gsApp/views/seerAutomation/settings.tsx | 2 +-
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
index 2b6be55fc57d77..d3e2e643345e2c 100644
--- a/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx
@@ -184,12 +184,13 @@ export function CodeReviewOverviewSection({
onClick={() => refetch()}
/>
-
-
-
- {t('Configure')}
-
-
+
+
+
+ {t('Configure')}
+
+
+
}
>
diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx
index 8aee364cc1ff57..f189c27c4f03dc 100644
--- a/static/gsApp/views/seerAutomation/settings.tsx
+++ b/static/gsApp/views/seerAutomation/settings.tsx
@@ -46,7 +46,7 @@ export function SeerAutomationSettings() {
const organization = useOrganization();
const canWrite = useCanWriteSettings();
- const showSeerOverview = true; // organization.features.includes('seer-overview');
+ const showSeerOverview = organization.features.includes('seer-overview');
const scmOverviewData = useSCMOverviewSection();
const autofixOverviewData = useAutofixOverviewData();
From 0d1fe153336b2f43402a8a343dec2659911d4720 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:16:17 -0700
Subject: [PATCH 27/29] fix issue while loading: shouldnt fallback to comparing
to seer
---
.../views/settings/seer/overview/autofixOverviewSection.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index 9a5f51f0bdf06d..88f49c40e7606b 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -189,9 +189,9 @@ function AgentNameForm({
const preferredAgentIntegration =
preferredAgentValue === 'seer'
? 'seer'
- : (rawAgentOptions
+ : rawAgentOptions
.filter(option => option.value !== 'seer')
- .find(option => option.value.id === preferredAgentValue)?.value ?? 'seer');
+ .find(option => option.value.id === preferredAgentValue)?.value;
const preferredAgentProjectIds = new Set(
projectsWithPreferredAgent.map(s => s.projectId)
From 6b9ff53ae47b33030d5cf620127ef80ae7ea3716 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:19:38 -0700
Subject: [PATCH 28/29] handle case when were loading agent options
---
.../seer/overview/autofixOverviewSection.tsx | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
index 88f49c40e7606b..4ce19c63456e61 100644
--- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
+++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx
@@ -9,6 +9,7 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink, Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {updateOrganization} from 'sentry/actionCreators/organizations';
import {
bulkAutofixAutomationSettingsInfiniteOptions,
@@ -235,12 +236,17 @@ function AgentNameForm({
!canWrite ||
isBulkMutatingAgent ||
isBulkMutatingCreatePr ||
+ !preferredAgentIntegration ||
projectsWithPreferredAgent.length === projects.length
}
onClick={async () => {
- setIsBulkMutatingAgent(true);
- await bulkMutateSelectedAgent(preferredAgentIntegration, {});
- setIsBulkMutatingAgent(false);
+ if (preferredAgentIntegration) {
+ setIsBulkMutatingAgent(true);
+ await bulkMutateSelectedAgent(preferredAgentIntegration, {});
+ setIsBulkMutatingAgent(false);
+ } else {
+ addErrorMessage(t('No coding agent integration found'));
+ }
}}
>
{tn(
From c1c2cc174776e135564eeec604d4fc0786ff53f7 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 09:22:09 -0700
Subject: [PATCH 29/29] mv organizationIntegrationsQueryOptions
---
.../useScmIntegrationTreeData.ts | 2 +-
.../organizationsIntegrationsQueryOptions.ts | 33 -------------------
.../organizationIntegrationsQueryOptions.ts | 17 ++++++++--
3 files changed, 15 insertions(+), 37 deletions(-)
delete mode 100644 static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts
diff --git a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts
index 0d3f8a947e05c2..709c22fc644a6b 100644
--- a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts
+++ b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts
@@ -2,7 +2,6 @@ import {useEffect, useMemo} from 'react';
import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories';
import {organizationConfigIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsConfigIntegrationsQueryOptions';
-import {organizationIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsIntegrationsQueryOptions';
import type {
IntegrationProvider,
IntegrationRepository,
@@ -12,6 +11,7 @@ import type {
import {apiOptions} from 'sentry/utils/api/apiOptions';
import {useInfiniteQuery, useQueries, useQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
+import {organizationIntegrationsQueryOptions} from 'sentry/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions';
type ScmIntegrationTreeData = {
connectedIdentifiers: Set;
diff --git a/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts b/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts
deleted file mode 100644
index 72d5bd85535592..00000000000000
--- a/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type {OrganizationIntegration} from 'sentry/types/integrations';
-import type {Organization} from 'sentry/types/organization';
-import {apiOptions} from 'sentry/utils/api/apiOptions';
-
-export function organizationIntegrationsQueryOptions({
- cursor,
- features = [],
- includeConfig = false,
- organization,
- providerKey,
- staleTime = 60_000,
-}: {
- organization: Organization;
- cursor?: string;
- features?: string[];
- includeConfig?: boolean;
- providerKey?: string;
- staleTime?: number;
-}) {
- return apiOptions.as()(
- '/organizations/$organizationIdOrSlug/integrations/',
- {
- path: {organizationIdOrSlug: organization.slug},
- query: {
- cursor,
- features,
- includeConfig: includeConfig ? 1 : 0,
- providerKey,
- },
- staleTime,
- }
- );
-}
diff --git a/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts b/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts
index 862aa7edb50c36..72d5bd85535592 100644
--- a/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts
+++ b/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts
@@ -3,19 +3,30 @@ import type {Organization} from 'sentry/types/organization';
import {apiOptions} from 'sentry/utils/api/apiOptions';
export function organizationIntegrationsQueryOptions({
+ cursor,
+ features = [],
+ includeConfig = false,
organization,
+ providerKey,
staleTime = 60_000,
- includeConfig = 0,
}: {
organization: Organization;
- includeConfig?: number;
+ cursor?: string;
+ features?: string[];
+ includeConfig?: boolean;
+ providerKey?: string;
staleTime?: number;
}) {
return apiOptions.as()(
'/organizations/$organizationIdOrSlug/integrations/',
{
path: {organizationIdOrSlug: organization.slug},
- query: {includeConfig},
+ query: {
+ cursor,
+ features,
+ includeConfig: includeConfig ? 1 : 0,
+ providerKey,
+ },
staleTime,
}
);