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', () => ( + + + + + + + +
+ + + )); + + story('All repos connected', () => ( + + + + + + + +
+ + + )); +}); 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 ( + + ); +} + +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 ( + + + + ); +} 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}
Date: Mon, 23 Mar 2026 15:09:36 -0700 Subject: [PATCH 02/29] review comment --- static/app/utils/api/apiFetch.tsx | 33 ++++++++++--------- .../seer/overview/scmOverviewSection.tsx | 11 +++++-- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/static/app/utils/api/apiFetch.tsx b/static/app/utils/api/apiFetch.tsx index 5ec60e1e43e6ff..8231c17c8666f7 100644 --- a/static/app/utils/api/apiFetch.tsx +++ b/static/app/utils/api/apiFetch.tsx @@ -1,10 +1,10 @@ import {useEffect} from 'react'; -import type {QueryFunctionContext, UseInfiniteQueryResult} from '@tanstack/react-query'; +import type {QueryFunctionContext} from '@tanstack/react-query'; import {parseQueryKey} from 'sentry/utils/api/apiQueryKey'; import type {ApiQueryKey, InfiniteApiQueryKey} from 'sentry/utils/api/apiQueryKey'; import type {ParsedHeader} from 'sentry/utils/parseLinkHeader'; -import {QUERY_API_CLIENT} from 'sentry/utils/queryClient'; +import {QUERY_API_CLIENT, type UseInfiniteQueryResult} from 'sentry/utils/queryClient'; export type ApiResponse = { headers: { @@ -41,20 +41,6 @@ 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> { @@ -83,3 +69,18 @@ export async function apiFetchInfinite( json: json as TQueryFnData, }; } + +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]); +} diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx index 1fbe8a64e5f68e..93d3ad17a2e923 100644 --- a/static/app/views/settings/seer/overview/scmOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx @@ -28,6 +28,7 @@ import type { } from 'sentry/types/integrations'; 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'; @@ -46,8 +47,12 @@ export function SCMOverviewSection({canWrite}: Props) { isError, } = useScmIntegrationTreeData(); - const supportedScmIntegrations = scmIntegrations.filter(i => - isSupportedAutofixProvider({id: i.provider.key, name: i.provider.name}) + const supportedScmIntegrations = useMemo( + () => + scmIntegrations.filter(i => + isSupportedAutofixProvider({id: i.provider.key, name: i.provider.name}) + ), + [scmIntegrations] ); const seerRepos = useMemo(() => { @@ -233,7 +238,7 @@ function ConnectAllReposButton({ integration: OrganizationIntegration; repo: IntegrationRepository; }) => - apiFetch({ + fetchMutation({ method: 'POST', url: getApiUrl('/organizations/$organizationIdOrSlug/repos/', { path: {organizationIdOrSlug: organization.slug}, From 42d12038800df01eb90eea4a17584017a6c7663d Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 23 Mar 2026 15:32:23 -0700 Subject: [PATCH 03/29] iterate on loading state --- .../views/settings/seer/overview/components.tsx | 15 +++++++++++++-- .../settings/seer/overview/scmOverviewSection.tsx | 7 +++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/static/app/views/settings/seer/overview/components.tsx b/static/app/views/settings/seer/overview/components.tsx index ca0e8b8287dacb..02fe8bf57ab9ec 100644 --- a/static/app/views/settings/seer/overview/components.tsx +++ b/static/app/views/settings/seer/overview/components.tsx @@ -3,6 +3,8 @@ import {type ReactNode} from 'react'; import {Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; + export function SeerOverview({children}: {children: ReactNode}) { return ( @@ -46,13 +48,22 @@ function SectionHeader({children, title}: {title: string; children?: ReactNode}) ); } -function Stat({value, label}: {label: string; value: string | number}) { +function Stat({ + value, + label, + isPending, +}: { + isPending: boolean; + label: string; + value: string | number; +}) { return ( {label} - + + {isPending ? : null} {value} diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx index 93d3ad17a2e923..371a2f7f49e250 100644 --- a/static/app/views/settings/seer/overview/scmOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx @@ -43,6 +43,7 @@ export function SCMOverviewSection({canWrite}: Props) { connectedIdentifiers, refetchIntegrations, reposByIntegrationId, + reposPendingByIntegrationId, isPending, isError, } = useScmIntegrationTreeData(); @@ -55,6 +56,7 @@ export function SCMOverviewSection({canWrite}: Props) { [scmIntegrations] ); + const isReposPending = Object.values(reposPendingByIntegrationId).some(Boolean); const seerRepos = useMemo(() => { return Object.entries(reposByIntegrationId ?? {}) .filter(([integrationId, _]) => { @@ -78,12 +80,13 @@ export function SCMOverviewSection({canWrite}: Props) { const stat = ( ); @@ -274,7 +277,7 @@ function ConnectAllReposButton({ } return ( - + - -
- + ({ + repo, + integration: GITHUB_INTEGRATION, + }))} + /> )); story('All repos connected', () => ( - - - - - - -
- + + + )); + + 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 ( + ); + } + 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 ( + + + { + 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:')} + + + + + + + ) : 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:')} + + + + + + + ) : 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')} + + + )} + + + +
+ + + + + + + + {field => ( + + + + )} + + + {isLoading ? ( +
+ ) : ( + + + + )} + + + + + {field => ( + + + + )} + + + {isLoading ? ( +
+ ) : ( + + + + )} + + ); +} 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 ? ( +
+ ) : ( + + + + )} + +
+ + + {field => ( + } + )} + > + + + )} + + + {isLoading ? ( +
+ ) : ( + + + + )} + + ); +} 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 ( - - ); - } - 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 ( - - - { - 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:')} - - - - - - - ) : 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:')} - - - - - - - ) : 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')} - - - )} - - - -
- - - - - - + + {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 + )} + + + + + )} - - {isLoading ? ( -
- ) : ( - - - - )} - - - {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 + )} + + + + + )} - - {isLoading ? ( -
- ) : ( - - - - )} - + ); } 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 + )} + + + + + )} - {isLoading ? ( -
- ) : ( - - - - )} - -
- {field => ( - } )} > - - + + + + + + + + + + )} - - {isLoading ? ( -
- ) : ( - - - - )} - + ); } 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 ( - + + + + {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 ( - + + + {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 ( - + - - - + 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 + )} + + + + + + )} + + {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 - )} - - - - - + + + + + + {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 - )} - - - - + + + - )} - - {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 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 - )} - - - - - + + + + {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 => ( - } - )} - > - + + } + )} + > - - - - - - + + + + + )} 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 => ( + + + + + + + + )} + + + ); +} 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) { - + - - {t( - '%s of %s existing projects use %s', - stats.projectsWithAutomationCount, - stats.totalProjects, - codingAgentOptions.find(option => option.value === field.state.value) - ?.label - )} - - - - )} - - - - {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 - )} - - + - {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 => ( + + + + + + + + + + + {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 => ( + + + ), + } + )} + > + + + + + + + + + {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({ - {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 ( - - - - - - {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, } );