From 8f598e97790316ec109c3179e388124ad1a2f50c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 20 Mar 2026 13:48:21 -0700 Subject: [PATCH 1/2] squash --- .../scmIntegrationTree/providerConfigLink.tsx | 4 +- .../repositories/scmRepoTreeModal.tsx | 10 +- static/app/utils/api/apiFetch.tsx | 18 +- .../seer/overview/seerOverview.stories.tsx | 175 ++++ .../settings/seer/overview/seerOverview.tsx | 387 +++++++ .../overview/useSeerOverviewData.spec.tsx | 269 +++++ .../seer/overview/useSeerOverviewData.tsx | 106 ++ .../organizationIntegrationsQueryOptions.ts | 22 + .../projectDetails/autofixAgent.tsx | 8 +- .../projectTable/seerProjectTableRow.tsx | 4 +- .../components/repoTable/seerRepoTable.tsx | 19 +- .../components/seerAgentHooks.spec.tsx | 944 ------------------ .../components/seerAgentHooks.tsx | 260 ----- .../gsApp/views/seerAutomation/settings.tsx | 22 +- 14 files changed, 1020 insertions(+), 1228 deletions(-) create mode 100644 static/app/views/settings/seer/overview/seerOverview.stories.tsx create mode 100644 static/app/views/settings/seer/overview/seerOverview.tsx create mode 100644 static/app/views/settings/seer/overview/useSeerOverviewData.spec.tsx create mode 100644 static/app/views/settings/seer/overview/useSeerOverviewData.tsx create mode 100644 static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts delete mode 100644 static/gsApp/views/seerAutomation/components/seerAgentHooks.spec.tsx delete mode 100644 static/gsApp/views/seerAutomation/components/seerAgentHooks.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/scmRepoTreeModal.tsx b/static/app/components/repositories/scmRepoTreeModal.tsx index 27074ef4a88aa6..057ebe3843cab1 100644 --- a/static/app/components/repositories/scmRepoTreeModal.tsx +++ b/static/app/components/repositories/scmRepoTreeModal.tsx @@ -8,16 +8,20 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {ScmIntegrationTree} from 'sentry/components/repositories/scmIntegrationTree/scmIntegrationTree'; import {ScmTreeFilters} from 'sentry/components/repositories/scmIntegrationTree/scmTreeFilters'; import type {RepoFilter} from 'sentry/components/repositories/scmIntegrationTree/types'; -import {t, tct} from 'sentry/locale'; +import {tct} from 'sentry/locale'; -export function ScmRepoTreeModal({Header, Body}: ModalRenderProps) { +interface Props extends ModalRenderProps { + title: string; +} + +export function ScmRepoTreeModal({Header, Body, title}: Props) { const [search, setSearch] = useState(''); const [repoFilter, setRepoFilter] = useState('all'); return (
- {t('Add Repository')} + {title}
diff --git a/static/app/utils/api/apiFetch.tsx b/static/app/utils/api/apiFetch.tsx index b4d49304267a77..8231c17c8666f7 100644 --- a/static/app/utils/api/apiFetch.tsx +++ b/static/app/utils/api/apiFetch.tsx @@ -1,9 +1,10 @@ +import {useEffect} from 'react'; 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: { @@ -68,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/seerOverview.stories.tsx b/static/app/views/settings/seer/overview/seerOverview.stories.tsx new file mode 100644 index 00000000000000..dc966ed315bffd --- /dev/null +++ b/static/app/views/settings/seer/overview/seerOverview.stories.tsx @@ -0,0 +1,175 @@ +import {Grid} from '@sentry/scraps/layout'; + +import * as Storybook from 'sentry/stories'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; +import { + AutofixOverviewSection, + CodeReviewOverviewSection, + SCMOverviewSection, +} from 'sentry/views/settings/seer/overview/seerOverview'; +import type {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData'; + +function OrganizationIntegrationsFixture( + params: Partial = {} +): OrganizationIntegration { + return { + accountType: '', + gracePeriodEnd: '', + organizationIntegrationStatus: 'active', + domainName: 'github.com', + icon: 'https://secure.gravatar.com/avatar/8b4cb68e40b74c90427d8262256bd1c8', + id: '5', + name: 'NisanthanNanthakumar', + provider: { + aspects: {}, + canAdd: true, + canDisable: false, + features: ['commits', 'issue-basic'], + key: 'github', + name: 'Github', + slug: 'github', + }, + status: 'active', + configData: null, + configOrganization: [], + externalId: 'ext-integration-1', + organizationId: '1', + ...params, + }; +} + +const seerIntegrationsFixture = [ + OrganizationIntegrationsFixture({id: '1', name: 'Integration A'}), + OrganizationIntegrationsFixture({id: '2', name: 'Integration B'}), +]; + +const baseStats: ReturnType['stats'] = { + integrationCount: 2, + scmIntegrationCount: 2, + seerIntegrations: seerIntegrationsFixture, + seerIntegrationCount: 2, + totalRepoCount: 10, + seerRepoCount: 10, // equal to totalRepoCount: no "Add all repos" button + reposWithSettingsCount: 10, + projectsWithReposCount: 6, // equal to totalProjects: no "Handoff all to" CompactSelect + projectsWithAutomationCount: 6, + projectsWithCreatePrCount: 6, + totalProjects: 6, + reposWithCodeReviewCount: 10, // equal to seerRepoCount +}; + +function SeerOverview({ + stats, + isLoading, +}: { + isLoading: boolean; + stats: ReturnType['stats']; +}) { + return ( + + + + + + ); +} + +export default Storybook.story('SeerOverview', story => { + story('No alerts (healthy state)', () => ( + + )); + + story('Loading state', () => ); + + // SCM stories + + story('SCM: No SCM integrations installed', () => ( + + )); + + story('SCM: Integrations installed but no repos connected', () => ( + + )); + + story('SCM: Some repos not yet added to Seer', () => ( + + )); + + // Autofix stories + + story('Autofix: No projects have repos linked', () => ( + + )); + + story('Autofix: Some projects with repos (partial)', () => ( + + )); + + // Code Review stories + + story('Code Review: No repos have code review enabled', () => ( + 0 → ButtonBar visible, shows 0/10 + }} + isLoading={false} + /> + )); +}); diff --git a/static/app/views/settings/seer/overview/seerOverview.tsx b/static/app/views/settings/seer/overview/seerOverview.tsx new file mode 100644 index 00000000000000..df3a2cfd0ff17f --- /dev/null +++ b/static/app/views/settings/seer/overview/seerOverview.tsx @@ -0,0 +1,387 @@ +import {Fragment, type ReactNode} from 'react'; +import {css} from '@emotion/react'; + +import {Button, ButtonBar} from '@sentry/scraps/button'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Flex, Grid} from '@sentry/scraps/layout'; +import {ExternalLink, Link} from '@sentry/scraps/link'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {openModal} from 'sentry/actionCreators/modal'; +import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; +import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink'; +import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal'; +import {IconAdd, IconCheckmark, IconClose, IconSettings} from 'sentry/icons'; +import {t, tct, tn} from 'sentry/locale'; +import {defined} from 'sentry/utils'; +import {useQuery} from 'sentry/utils/queryClient'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData'; + +import {useAgentOptions} from 'getsentry/views/seerAutomation/components/seerAgentHooks'; + +function formatStatValue(value: number, outOf: number | undefined, isLoading: boolean) { + if (isLoading) { + return '\u2014'; + } + return outOf === undefined ? value : `${value}\u2009/\u2009${outOf}`; +} + +function Section({children}: {children?: ReactNode}) { + return ( + + {children} + + ); +} + +function SectionHeader({children, title}: {title: string; children?: ReactNode}) { + return ( + + + {title} + + {children} + + ); +} + +function StatRow({ + value, + label, + children, +}: { + label: string; + value: string | number; + children?: ReactNode; +}) { + return ( + + + {value} + + + {label} + + + {children} + + + ); +} + +interface Props { + isLoading: boolean; + stats: ReturnType['stats']; +} + +export function SCMOverviewSection({stats, isLoading}: Props) { + const organization = useOrganization(); + + return ( +
+ + {!isLoading && stats.seerIntegrationCount > 0 && stats.seerRepoCount > 0 ? ( + + + {t('Configure')} + + + ) : null} + + + + + + + + + +
+ ); +} + +function SCMProviderWidgets({stats, isLoading}: Props) { + if (isLoading) { + return null; + } + if (stats.seerIntegrationCount === 0) { + return ( + + ); + } + 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, + } + ); +} diff --git a/static/gsApp/views/seerAutomation/components/projectDetails/autofixAgent.tsx b/static/gsApp/views/seerAutomation/components/projectDetails/autofixAgent.tsx index 9f25651cca0cd9..3c81553ae7d697 100644 --- a/static/gsApp/views/seerAutomation/components/projectDetails/autofixAgent.tsx +++ b/static/gsApp/views/seerAutomation/components/projectDetails/autofixAgent.tsx @@ -20,14 +20,14 @@ import {t, tct} from 'sentry/locale'; import type {Project} from 'sentry/types/project'; import {useQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; - -import {CodingAgentSettings} from 'getsentry/views/seerAutomation/components/projectDetails/agentSettings/codingAgentSettings'; -import {SeerAgentSettings} from 'getsentry/views/seerAutomation/components/projectDetails/agentSettings/seerAgentSettings'; import { useAgentOptions, useMutateSelectedAgent, useSelectedAgentFromProjectSettings, -} from 'getsentry/views/seerAutomation/components/seerAgentHooks'; +} from 'sentry/views/settings/seer/seerAgentHooks'; + +import {CodingAgentSettings} from 'getsentry/views/seerAutomation/components/projectDetails/agentSettings/codingAgentSettings'; +import {SeerAgentSettings} from 'getsentry/views/seerAutomation/components/projectDetails/agentSettings/seerAgentSettings'; interface Props { canWrite: boolean; diff --git a/static/gsApp/views/seerAutomation/components/projectTable/seerProjectTableRow.tsx b/static/gsApp/views/seerAutomation/components/projectTable/seerProjectTableRow.tsx index af55525ba0c7da..a2f6aaccb71dac 100644 --- a/static/gsApp/views/seerAutomation/components/projectTable/seerProjectTableRow.tsx +++ b/static/gsApp/views/seerAutomation/components/projectTable/seerProjectTableRow.tsx @@ -16,13 +16,13 @@ import {IconWarning} from 'sentry/icons/iconWarning'; import {t, tct} from 'sentry/locale'; import type {Project} from 'sentry/types/project'; import {useOrganization} from 'sentry/utils/useOrganization'; - import { useAgentOptions, useMutateCreatePr, useMutateSelectedAgent, useSelectedAgentFromBulkSettings, -} from 'getsentry/views/seerAutomation/components/seerAgentHooks'; +} from 'sentry/views/settings/seer/seerAgentHooks'; + import {useCanWriteSettings} from 'getsentry/views/seerAutomation/components/useCanWriteSettings'; interface Props { diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx index 132c471ec8e4ef..95e1fcb4651487 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx @@ -158,14 +158,17 @@ export function SeerRepoTable() { priority="primary" icon={} onClick={() => { - openModal(deps => , { - modalCss: css` - width: 700px; - `, - onClose: () => { - queryClient.invalidateQueries({queryKey: queryOptions.queryKey}); - }, - }); + openModal( + deps => , + { + modalCss: css` + width: 700px; + `, + onClose: () => { + queryClient.invalidateQueries({queryKey: queryOptions.queryKey}); + }, + } + ); }} > {t('Add Repository')} diff --git a/static/gsApp/views/seerAutomation/components/seerAgentHooks.spec.tsx b/static/gsApp/views/seerAutomation/components/seerAgentHooks.spec.tsx deleted file mode 100644 index 3e91088678eda8..00000000000000 --- a/static/gsApp/views/seerAutomation/components/seerAgentHooks.spec.tsx +++ /dev/null @@ -1,944 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; - -import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; -import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; -import { - CodingAgentProvider, - type ProjectSeerPreferences, -} from 'sentry/components/events/autofix/types'; -import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; -import {ProjectsStore} from 'sentry/stores/projectsStore'; -import {useQueryClient} from 'sentry/utils/queryClient'; - -import { - useAgentOptions, - useMutateCreatePr, - useMutateSelectedAgent, - useSelectedAgentFromBulkSettings, - useSelectedAgentFromProjectSettings, -} from 'getsentry/views/seerAutomation/components/seerAgentHooks'; - -describe('seerAgentHooks', () => { - const organization = OrganizationFixture({slug: 'org-slug'}); - const project = ProjectFixture({slug: 'project-slug', id: '1'}); - - beforeEach(() => { - ProjectsStore.loadInitialData([project]); - }); - - afterEach(() => { - MockApiClient.clearMockResponses(); - jest.resetAllMocks(); - ProjectsStore.reset(); - }); - - describe('useAgentOptions', () => { - it('returns Seer, integration options, and No Handoff Selection', () => { - const integrations: CodingAgentIntegration[] = [ - {id: '42', name: 'Cursor', provider: 'cursor'}, - ]; - - const {result} = renderHookWithProviders(useAgentOptions, { - initialProps: {integrations}, - organization, - }); - - const options = result.current; - expect(options).toHaveLength(3); - expect(options[0]).toEqual({value: 'seer', label: expect.any(String)}); - expect(options[1]).toMatchObject({ - value: {id: '42', name: 'Cursor', provider: 'cursor'}, - label: 'Cursor', - }); - expect(options[2]).toEqual({value: 'none', label: 'No Handoff'}); - }); - - it('filters out integrations without id', () => { - const integrations: CodingAgentIntegration[] = [ - {id: null, name: 'No Id', provider: 'other'}, - {id: '1', name: 'With Id', provider: 'cursor'}, - ]; - - const {result} = renderHookWithProviders(useAgentOptions, { - initialProps: {integrations}, - organization, - }); - - const options = result.current; - expect(options).toHaveLength(3); - expect(options[1]!.value).toMatchObject({id: '1', name: 'With Id'}); - }); - }); - - describe('useSelectedAgentFromProjectSettings', () => { - it('returns "none" when project autofixAutomationTuning is off', () => { - const p = ProjectFixture({...project, autofixAutomationTuning: 'off'}); - - const {result} = renderHookWithProviders(useSelectedAgentFromProjectSettings, { - initialProps: { - preference: {repositories: []}, - project: p, - integrations: [], - }, - organization, - }); - - expect(result.current).toBe('none'); - }); - - it('returns "seer" when no automation_handoff integration_id', () => { - const {result} = renderHookWithProviders(useSelectedAgentFromProjectSettings, { - initialProps: { - preference: {repositories: []}, - project, - integrations: [], - }, - organization, - }); - - expect(result.current).toBe('seer'); - }); - - it('returns matching integration when automation_handoff has integration_id', () => { - const integrations: CodingAgentIntegration[] = [ - {id: '99', name: 'Cursor', provider: 'cursor'}, - ]; - - const {result} = renderHookWithProviders(useSelectedAgentFromProjectSettings, { - initialProps: { - preference: { - repositories: [], - automation_handoff: { - handoff_point: 'root_cause', - target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, - integration_id: 99, - }, - }, - project, - integrations, - }, - organization, - }); - - expect(result.current).toMatchObject({id: '99', name: 'Cursor'}); - }); - }); - - describe('useSelectedAgentFromBulkSettings', () => { - it('returns "none" when autofixAutomationTuning is off', () => { - const {result} = renderHookWithProviders(useSelectedAgentFromBulkSettings, { - initialProps: { - autofixSettings: { - projectId: '1', - autofixAutomationTuning: 'off', - automatedRunStoppingPoint: undefined, - automationHandoff: undefined, - reposCount: 0, - }, - integrations: [], - }, - organization, - }); - - expect(result.current).toBe('none'); - }); - - it('returns "seer" when no automationHandoff integration_id', () => { - const {result} = renderHookWithProviders(useSelectedAgentFromBulkSettings, { - initialProps: { - autofixSettings: { - projectId: '1', - autofixAutomationTuning: 'medium', - automatedRunStoppingPoint: undefined, - automationHandoff: undefined, - reposCount: 0, - }, - integrations: [], - }, - organization, - }); - - expect(result.current).toBe('seer'); - }); - - it('returns matching integration when automationHandoff has integration_id', () => { - const integrations: CodingAgentIntegration[] = [ - {id: '99', name: 'Cursor', provider: 'cursor'}, - ]; - - const {result} = renderHookWithProviders(useSelectedAgentFromBulkSettings, { - initialProps: { - autofixSettings: { - projectId: '1', - autofixAutomationTuning: 'medium', - automatedRunStoppingPoint: undefined, - automationHandoff: { - handoff_point: 'root_cause', - target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, - integration_id: 99, - }, - reposCount: 0, - }, - integrations, - }, - organization, - }); - - expect(result.current).toMatchObject({id: '99', name: 'Cursor'}); - }); - }); - - describe('useMutateSelectedAgent', () => { - const basePreference: ProjectSeerPreferences = { - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }; - - const queryKey = bulkAutofixAutomationSettingsInfiniteOptions({ - organization, - }).queryKey; - - function makeInitialCacheData() { - return { - pages: [ - { - json: [ - { - projectId: '1', - autofixAutomationTuning: 'off' as const, - automatedRunStoppingPoint: 'code_changes' as const, - automationHandoff: undefined, - reposCount: 2, - }, - { - projectId: '2', - autofixAutomationTuning: 'medium' as const, - automatedRunStoppingPoint: 'open_pr' as const, - automationHandoff: undefined, - reposCount: 1, - }, - ], - headers: { - Link: undefined, - 'X-Hits': undefined, - 'X-Max-Hits': undefined, - }, - }, - ], - pageParams: [undefined], - }; - } - - function setupMocks(preference: ProjectSeerPreferences = basePreference) { - const seerPreferencesGetRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'GET', - body: { - preference, - code_mapping_repos: [], - } satisfies SeerPreferencesResponse, - }); - const projectPutRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/`, - method: 'PUT', - body: project, - }); - const seerPreferencesPostRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'POST', - body: {}, - }); - return {seerPreferencesGetRequest, projectPutRequest, seerPreferencesPostRequest}; - } - - function renderMutateSelectedAgent() { - return renderHookWithProviders( - (props: {project: typeof project}) => { - const queryClient = useQueryClient(); - const mutate = useMutateSelectedAgent(props); - return {mutate, queryClient}; - }, - { - initialProps: {project}, - organization, - } - ); - } - - it('sends correct API requests when integration is "seer"', async () => { - const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(projectPutRequest).toHaveBeenCalledTimes(1); - }); - expect(projectPutRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/`, - expect.objectContaining({ - method: 'PUT', - data: {autofixAutomationTuning: 'medium'}, - }) - ); - - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.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 when integration is "none"', async () => { - const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate('none', {}); - }); - - await waitFor(() => { - expect(projectPutRequest).toHaveBeenCalledTimes(1); - }); - expect(projectPutRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/`, - expect.objectContaining({ - method: 'PUT', - data: {autofixAutomationTuning: 'off'}, - }) - ); - - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.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 when integration is a CodingAgentIntegration', async () => { - const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); - const integration: CodingAgentIntegration = { - id: '123', - name: 'Cursor', - provider: 'cursor', - }; - - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate(integration, {}); - }); - - await waitFor(() => { - expect(projectPutRequest).toHaveBeenCalledTimes(1); - }); - expect(projectPutRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/`, - expect.objectContaining({ - method: 'PUT', - data: {autofixAutomationTuning: 'medium'}, - }) - ); - - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: { - handoff_point: 'root_cause', - target: 'cursor_background_agent', - integration_id: 123, - auto_create_pr: false, - }, - }), - }) - ); - }); - - it('sets auto_create_pr from preference when integration is CodingAgentIntegration and stopping point is open_pr', async () => { - const {seerPreferencesPostRequest} = setupMocks({ - ...basePreference, - automated_run_stopping_point: 'open_pr', - }); - const integration: CodingAgentIntegration = { - id: '456', - name: 'Cursor', - provider: 'cursor', - }; - - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate(integration, {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - }); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - expect.objectContaining({ - data: expect.objectContaining({ - automation_handoff: expect.objectContaining({ - integration_id: 456, - auto_create_pr: true, - }), - }), - }) - ); - }); - - it('passes through preference repositories and automated_run_stopping_point for all integration types', async () => { - const preferenceWithRepos: ProjectSeerPreferences = { - repositories: [ - { - external_id: 'repo-1', - name: 'my-repo', - owner: 'my-org', - provider: 'github', - }, - ], - automated_run_stopping_point: 'open_pr', - automation_handoff: undefined, - }; - - const {seerPreferencesPostRequest} = setupMocks(preferenceWithRepos); - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - }); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - expect.objectContaining({ - data: expect.objectContaining({ - repositories: [ - { - external_id: 'repo-1', - name: 'my-repo', - owner: 'my-org', - provider: 'github', - }, - ], - automated_run_stopping_point: 'open_pr', - }), - }) - ); - }); - - it('calls onSuccess when both requests succeed', async () => { - setupMocks(); - const onSuccess = jest.fn(); - - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {onSuccess}); - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - - it('calls onError when a request fails', async () => { - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'GET', - body: { - preference: basePreference, - code_mapping_repos: [], - } satisfies SeerPreferencesResponse, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/`, - method: 'PUT', - statusCode: 500, - body: {}, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'POST', - body: {}, - }); - const onError = jest.fn(); - - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {onError}); - }); - - await waitFor(() => { - expect(onError).toHaveBeenCalledTimes(1); - }); - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - }); - - it('optimistically updates the infinite query cache when selecting "seer"', () => { - setupMocks(); - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); - }); - - act(() => { - result.current.mutate('seer', {}); - }); - - const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< - typeof makeInitialCacheData - >; - expect(cached.pages[0]!.json[0]).toMatchObject({ - projectId: '1', - autofixAutomationTuning: 'medium', - automationHandoff: undefined, - }); - // Other project should not be affected - expect(cached.pages[0]!.json[1]).toMatchObject({ - projectId: '2', - autofixAutomationTuning: 'medium', - }); - }); - - it('optimistically updates the infinite query cache when selecting "none"', () => { - setupMocks(); - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); - }); - - act(() => { - result.current.mutate('none', {}); - }); - - const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< - typeof makeInitialCacheData - >; - expect(cached.pages[0]!.json[0]).toMatchObject({ - projectId: '1', - autofixAutomationTuning: 'off', - automationHandoff: undefined, - }); - }); - - it('optimistically updates the infinite query cache when selecting a CodingAgentIntegration', async () => { - setupMocks(); - const integration: CodingAgentIntegration = { - id: '123', - name: 'Cursor', - provider: 'cursor', - }; - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); - }); - - act(() => { - result.current.mutate(integration, {}); - }); - - await waitFor(() => { - const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< - typeof makeInitialCacheData - >; - expect(cached.pages[0]!.json[0]).toMatchObject({ - projectId: '1', - autofixAutomationTuning: 'medium', - automationHandoff: { - handoff_point: 'root_cause', - target: 'cursor_background_agent', - integration_id: 123, - auto_create_pr: false, - }, - }); - }); - }); - - it('updates ProjectsStore when selecting "seer"', () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - expect(storeSpy).toHaveBeenCalledWith({ - id: '1', - autofixAutomationTuning: 'medium', - }); - }); - - it('updates ProjectsStore when selecting "none"', () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderMutateSelectedAgent(); - - act(() => { - result.current.mutate('none', {}); - }); - - expect(storeSpy).toHaveBeenCalledWith({ - id: '1', - autofixAutomationTuning: 'off', - }); - }); - }); - - describe('useMutateCreatePr', () => { - const basePreference: ProjectSeerPreferences = { - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }; - - const queryKey = bulkAutofixAutomationSettingsInfiniteOptions({ - organization, - }).queryKey; - - function makeInitialCacheData() { - return { - pages: [ - { - json: [ - { - projectId: '1', - autofixAutomationTuning: 'medium' as const, - automatedRunStoppingPoint: - 'code_changes' as ProjectSeerPreferences['automated_run_stopping_point'], - automationHandoff: undefined, - reposCount: 2, - }, - ], - headers: { - Link: undefined, - 'X-Hits': undefined, - 'X-Max-Hits': undefined, - }, - }, - ], - pageParams: [undefined], - }; - } - - function setupMocks(preference: ProjectSeerPreferences = basePreference) { - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'GET', - body: { - preference, - code_mapping_repos: [], - } satisfies SeerPreferencesResponse, - }); - const seerPreferencesPostRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'POST', - body: {}, - }); - return {seerPreferencesPostRequest}; - } - - function renderMutateCreatePr() { - return renderHookWithProviders( - (props: {project: typeof project}) => { - const queryClient = useQueryClient(); - const mutate = useMutateCreatePr(props); - return {mutate, queryClient}; - }, - { - initialProps: {project}, - organization, - } - ); - } - - describe('with seer agent', () => { - it('sends correct API request when enabling PR creation', async () => { - const {seerPreferencesPostRequest} = setupMocks(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate('seer', true, {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - }); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - repositories: [], - automated_run_stopping_point: 'open_pr', - automation_handoff: undefined, - }), - }) - ); - }); - - it('sends correct API request when disabling PR creation', async () => { - const {seerPreferencesPostRequest} = setupMocks(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate('seer', false, {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - }); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - expect.objectContaining({ - data: expect.objectContaining({ - automated_run_stopping_point: 'code_changes', - }), - }) - ); - }); - - it('optimistically updates the cache with automatedRunStoppingPoint', () => { - setupMocks(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); - }); - - act(() => { - result.current.mutate('seer', true, {}); - }); - - const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< - typeof makeInitialCacheData - >; - expect(cached.pages[0]!.json[0]).toMatchObject({ - projectId: '1', - automatedRunStoppingPoint: 'open_pr', - }); - }); - - it('optimistically updates the cache to code_changes when disabling', () => { - setupMocks(); - const initialData = makeInitialCacheData(); - initialData.pages[0]!.json[0]!.automatedRunStoppingPoint = 'open_pr' as const; - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.queryClient.setQueryData(queryKey, initialData); - }); - - act(() => { - result.current.mutate('seer', false, {}); - }); - - const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< - typeof makeInitialCacheData - >; - expect(cached.pages[0]!.json[0]).toMatchObject({ - automatedRunStoppingPoint: 'code_changes', - }); - }); - - it('does not update ProjectsStore (no tuning change)', () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate('seer', true, {}); - }); - - expect(storeSpy).not.toHaveBeenCalled(); - }); - }); - - describe('with external agent (CodingAgentIntegration)', () => { - const integration: CodingAgentIntegration = { - id: '123', - name: 'Cursor', - provider: 'cursor', - }; - - it('sends correct API request when enabling PR creation', async () => { - const {seerPreferencesPostRequest} = setupMocks(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate(integration, true, {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - }); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: expect.objectContaining({ - handoff_point: 'root_cause', - target: 'cursor_background_agent', - integration_id: 123, - auto_create_pr: true, - }), - }), - }) - ); - }); - - it('sends correct API request when disabling PR creation', async () => { - const {seerPreferencesPostRequest} = setupMocks(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate(integration, false, {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); - }); - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - expect.objectContaining({ - data: expect.objectContaining({ - automation_handoff: expect.objectContaining({ - auto_create_pr: false, - }), - }), - }) - ); - }); - - it('optimistically updates the cache with automationHandoff', async () => { - setupMocks(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); - }); - - act(() => { - result.current.mutate(integration, true, {}); - }); - - await waitFor(() => { - const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< - typeof makeInitialCacheData - >; - expect(cached.pages[0]!.json[0]).toMatchObject({ - projectId: '1', - automationHandoff: { - handoff_point: 'root_cause', - target: 'cursor_background_agent', - integration_id: 123, - auto_create_pr: true, - }, - }); - }); - }); - - it('does not update ProjectsStore (no tuning change)', () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate(integration, true, {}); - }); - - expect(storeSpy).not.toHaveBeenCalled(); - }); - }); - - describe('with "none" agent', () => { - it('does not make any API calls', () => { - const {seerPreferencesPostRequest} = setupMocks(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate('none', true, {}); - }); - - expect(seerPreferencesPostRequest).not.toHaveBeenCalled(); - }); - }); - - it('calls onSuccess when the request succeeds', async () => { - setupMocks(); - const onSuccess = jest.fn(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate('seer', true, {onSuccess}); - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - - it('calls onError when the request fails', async () => { - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'GET', - body: { - preference: basePreference, - code_mapping_repos: [], - } satisfies SeerPreferencesResponse, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'POST', - statusCode: 500, - body: {}, - }); - const onError = jest.fn(); - const {result} = renderMutateCreatePr(); - - act(() => { - result.current.mutate('seer', true, {onError}); - }); - - await waitFor(() => { - expect(onError).toHaveBeenCalledTimes(1); - }); - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - }); - }); -}); diff --git a/static/gsApp/views/seerAutomation/components/seerAgentHooks.tsx b/static/gsApp/views/seerAutomation/components/seerAgentHooks.tsx deleted file mode 100644 index 4c19a50cf6410d..00000000000000 --- a/static/gsApp/views/seerAutomation/components/seerAgentHooks.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import {useCallback, useMemo} from 'react'; - -import { - bulkAutofixAutomationSettingsInfiniteOptions, - type AutofixAutomationSettings, -} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; -import { - useFetchProjectSeerPreferences, - useUpdateProjectSeerPreferences, -} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; -import {PROVIDER_TO_HANDOFF_TARGET} from 'sentry/components/events/autofix/types'; -import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; -import {type CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; -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 {useOrganization} from 'sentry/utils/useOrganization'; - -export function useAgentOptions({ - integrations, -}: { - integrations: CodingAgentIntegration[]; -}) { - return useMemo(() => { - return [ - {value: 'seer' as const, label: t('Seer Agent')}, - ...integrations - .filter(integration => integration.id) - .map(integration => ({ - value: integration, - label: integration.name, - })), - {value: 'none' as const, label: t('No Handoff')}, - ]; - }, [integrations]); -} - -export function useSelectedAgentFromProjectSettings({ - integrations, - preference, - project, -}: { - integrations: CodingAgentIntegration[]; - preference: ProjectSeerPreferences; - project: Project; -}) { - return useMemo(() => { - // If we have autofixAutomationTuning==OFF then 'none' is picked - if (project.autofixAutomationTuning === 'off') { - return 'none'; - } - // If we have nothing in preferences, then we have Seer - if (!preference?.automation_handoff?.integration_id) { - return 'seer'; - } - // Otherwise, we have a preference! - return integrations.find( - integration => - integration.id === String(preference.automation_handoff?.integration_id) - ); - }, [ - preference?.automation_handoff?.integration_id, - project.autofixAutomationTuning, - integrations, - ]); -} - -export function useSelectedAgentFromBulkSettings({ - autofixSettings, - integrations, -}: { - autofixSettings: AutofixAutomationSettings; - integrations: CodingAgentIntegration[]; -}) { - return useMemo(() => { - // If we have autofixAutomationTuning==OFF then 'none' is picked - if (autofixSettings.autofixAutomationTuning === 'off') { - return 'none'; - } - // If we have nothing in preferences, then we have Seer - if (!autofixSettings?.automationHandoff?.integration_id) { - return 'seer'; - } - // Otherwise, we have a preference! - return integrations.find( - integration => - integration.id === String(autofixSettings.automationHandoff?.integration_id) - ); - }, [ - autofixSettings.automationHandoff?.integration_id, - autofixSettings.autofixAutomationTuning, - integrations, - ]); -} - -type MutateOptions = { - onError?: (error: Error) => void; - onSuccess?: () => void; -}; - -function useApplyOptimisticUpdate({project}: {project: Project}) { - const queryClient = useQueryClient(); - const organization = useOrganization(); - const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ - organization, - }); - - return useCallback( - (updates: Partial) => { - queryClient.setQueryData(autofixSettingsQueryOptions.queryKey, oldData => { - if (!oldData) { - return oldData; - } - return { - ...oldData, - pages: oldData.pages.map(page => ({ - ...page, - json: page.json.map(setting => - String(setting.projectId) === project.id - ? {...setting, ...updates} - : setting - ), - })), - }; - }); - - if ( - updates.autofixAutomationTuning !== undefined && - updates.autofixAutomationTuning !== null - ) { - ProjectsStore.onUpdateSuccess({ - id: project.id, - autofixAutomationTuning: updates.autofixAutomationTuning, - }); - } - }, - [queryClient, autofixSettingsQueryOptions.queryKey, project.id] - ); -} - -export function useMutateSelectedAgent({project}: {project: Project}) { - const {mutateAsync: updateProject} = useUpdateProject(project); - const {mutateAsync: updateProjectSeerPreferences} = - useUpdateProjectSeerPreferences(project); - const applyOptimisticUpdate = useApplyOptimisticUpdate({project}); - const fetchPreferences = useFetchProjectSeerPreferences({project}); - - return useCallback( - ( - integration: 'seer' | 'none' | CodingAgentIntegration, - {onSuccess, onError}: MutateOptions - ) => { - if (integration === 'seer' || integration === 'none') { - const tuning = integration === 'seer' ? 'medium' : 'off'; - applyOptimisticUpdate({ - autofixAutomationTuning: tuning, - automationHandoff: undefined, - }); - - fetchPreferences() - .then(preference => - Promise.all([ - updateProject({autofixAutomationTuning: tuning}), - updateProjectSeerPreferences({ - repositories: preference?.repositories ?? [], - automated_run_stopping_point: preference?.automated_run_stopping_point, - automation_handoff: undefined, - }), - ]) - ) - .then(() => onSuccess?.()) - .catch(() => onError?.(new Error('Failed to update agent setting'))); - } else { - applyOptimisticUpdate({ - autofixAutomationTuning: 'medium', - }); - - fetchPreferences() - .then(preference => { - const handoff: ProjectSeerPreferences['automation_handoff'] = 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; - - applyOptimisticUpdate({ - automationHandoff: handoff, - }); - - return Promise.all([ - updateProject({autofixAutomationTuning: 'medium'}), - updateProjectSeerPreferences({ - repositories: preference?.repositories ?? [], - automated_run_stopping_point: preference?.automated_run_stopping_point, - automation_handoff: handoff, - }), - ]); - }) - .then(() => onSuccess?.()) - .catch(() => onError?.(new Error('Failed to update agent setting'))); - } - }, - [updateProject, updateProjectSeerPreferences, applyOptimisticUpdate, fetchPreferences] - ); -} - -export function useMutateCreatePr({project}: {project: Project}) { - const {mutateAsync: updateProjectSeerPreferences} = - useUpdateProjectSeerPreferences(project); - const applyOptimisticUpdate = useApplyOptimisticUpdate({project}); - const fetchPreferences = useFetchProjectSeerPreferences({project}); - - return useCallback( - ( - autofixAgent: 'seer' | 'none' | CodingAgentIntegration | undefined, - value: boolean, - {onSuccess, onError}: MutateOptions - ) => { - if (autofixAgent === 'seer') { - const stoppingPoint = value ? ('open_pr' as const) : ('code_changes' as const); - applyOptimisticUpdate({automatedRunStoppingPoint: stoppingPoint}); - fetchPreferences() - .then(preference => - updateProjectSeerPreferences({ - repositories: preference?.repositories ?? [], - automated_run_stopping_point: stoppingPoint, - automation_handoff: preference?.automation_handoff, - }) - ) - .then(() => onSuccess?.()) - .catch(() => onError?.(new Error('Failed to update PR setting'))); - } else if (autofixAgent && autofixAgent !== 'none') { - fetchPreferences() - .then(preference => { - const updatedHandoff = { - handoff_point: 'root_cause' as const, - integration_id: Number(autofixAgent.id), - ...preference?.automation_handoff, - target: PROVIDER_TO_HANDOFF_TARGET[autofixAgent.provider]!, - auto_create_pr: value, - }; - applyOptimisticUpdate({automationHandoff: updatedHandoff}); - return updateProjectSeerPreferences({ - repositories: preference?.repositories ?? [], - automated_run_stopping_point: preference?.automated_run_stopping_point, - automation_handoff: updatedHandoff, - }); - }) - .then(() => onSuccess?.()) - .catch(() => onError?.(new Error('Failed to update PR setting'))); - } - }, - [updateProjectSeerPreferences, applyOptimisticUpdate, fetchPreferences] - ); -} diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx index 08bf20cbfa54d8..e75f4da5f0438a 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,12 @@ 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 { + AutofixOverviewSection, + CodeReviewOverviewSection, + SCMOverviewSection, +} from 'sentry/views/settings/seer/overview/seerOverview'; +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'; @@ -19,6 +25,7 @@ import {useCanWriteSettings} from 'getsentry/views/seerAutomation/components/use export function SeerAutomationSettings() { const organization = useOrganization(); const canWrite = useCanWriteSettings(); + const {stats, isLoading} = useSeerOverviewData(); return ( @@ -29,10 +36,10 @@ export function SeerAutomationSettings() { `Configure how Seer works with your codebase. Seer includes [autofix:Autofix] and [code_review:Code Review]. Autofix will triage your Issues as they are created, and can automatically send them to a coding agent for Root Cause Analysis, Solution generation, and PR creation. Code Review will review your pull requests to detect issues before they happen. [docs:Read the docs] to learn what Seer can do.`, { autofix: ( - + ), code_review: ( - + ), docs: ( @@ -41,6 +48,11 @@ export function SeerAutomationSettings() { )} /> + + + + +
{t( @@ -196,7 +208,7 @@ export function SeerAutomationSettings() { 'Enable Seer workflows that streamline creating code changes for your review, such as the ability to create pull requests or branches. [docs:Read the docs] to learn more.', { docs: ( - + ), } )} From 4549dffbee529bdd7e425577067be22a1c7b32ec Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sun, 22 Mar 2026 09:35:05 -0700 Subject: [PATCH 2/2] split it up[ --- static/app/types/organization.tsx | 2 + .../seer/overview/autofixOverviewSection.tsx | 205 ++++ .../overview/codeReviewOverviewSection.tsx | 157 +++ .../settings/seer/overview/components.tsx | 83 ++ .../seer/overview/scmOverviewSection.tsx | 146 +++ .../seer/overview/seerOverview.stories.tsx | 33 +- .../settings/seer/overview/seerOverview.tsx | 387 ------- .../settings/seer/seerAgentHooks.spec.tsx | 943 ++++++++++++++++++ .../views/settings/seer/seerAgentHooks.tsx | 260 +++++ .../gsApp/views/seerAutomation/settings.tsx | 242 +++-- 10 files changed, 1930 insertions(+), 528 deletions(-) create mode 100644 static/app/views/settings/seer/overview/autofixOverviewSection.tsx create mode 100644 static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx create mode 100644 static/app/views/settings/seer/overview/components.tsx create mode 100644 static/app/views/settings/seer/overview/scmOverviewSection.tsx delete mode 100644 static/app/views/settings/seer/overview/seerOverview.tsx create mode 100644 static/app/views/settings/seer/seerAgentHooks.spec.tsx create mode 100644 static/app/views/settings/seer/seerAgentHooks.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..2183218e7e9254 --- /dev/null +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -0,0 +1,205 @@ +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..a86a5a6a3f4f0c --- /dev/null +++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx @@ -0,0 +1,157 @@ +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/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.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx new file mode 100644 index 00000000000000..f31b391046fe50 --- /dev/null +++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx @@ -0,0 +1,146 @@ +import {css} from '@emotion/react'; + +import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; +import {ExternalLink, Link} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; + +import {openModal} from 'sentry/actionCreators/modal'; +import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink'; +import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal'; +import {IconAdd, IconSettings} from 'sentry/icons'; +import {t, tct, tn} from 'sentry/locale'; +import {defined} from 'sentry/utils'; +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 SCMOverviewSection({stats, isLoading}: Props) { + const organization = useOrganization(); + + return ( + + + {isLoading ? null : ( + + + {t('Configure')} + + + )} + + + + + + + + ); +} + +function SCMProviderWidgets({stats, isLoading}: Props) { + if (isLoading) { + return
; + } + if (stats.seerIntegrationCount === 0) { + return ( + + ); + } + return
; +} + +function SCMReposWidgets({stats, isLoading}: Props) { + if (isLoading || stats.seerIntegrationCount === 0) { + return
; + } + 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
; +} 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/app/views/settings/seer/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx new file mode 100644 index 00000000000000..d7a0f5eaedb191 --- /dev/null +++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx @@ -0,0 +1,943 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import { + CodingAgentProvider, + type ProjectSeerPreferences, +} from 'sentry/components/events/autofix/types'; +import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import {useQueryClient} from 'sentry/utils/queryClient'; +import { + useAgentOptions, + useMutateCreatePr, + useMutateSelectedAgent, + useSelectedAgentFromBulkSettings, + useSelectedAgentFromProjectSettings, +} from 'sentry/views/settings/seer/seerAgentHooks'; + +describe('seerAgentHooks', () => { + const organization = OrganizationFixture({slug: 'org-slug'}); + const project = ProjectFixture({slug: 'project-slug', id: '1'}); + + beforeEach(() => { + ProjectsStore.loadInitialData([project]); + }); + + afterEach(() => { + MockApiClient.clearMockResponses(); + jest.resetAllMocks(); + ProjectsStore.reset(); + }); + + describe('useAgentOptions', () => { + it('returns Seer, integration options, and No Handoff Selection', () => { + const integrations: CodingAgentIntegration[] = [ + {id: '42', name: 'Cursor', provider: 'cursor'}, + ]; + + const {result} = renderHookWithProviders(useAgentOptions, { + initialProps: {integrations}, + organization, + }); + + const options = result.current; + expect(options).toHaveLength(3); + expect(options[0]).toEqual({value: 'seer', label: expect.any(String)}); + expect(options[1]).toMatchObject({ + value: {id: '42', name: 'Cursor', provider: 'cursor'}, + label: 'Cursor', + }); + expect(options[2]).toEqual({value: 'none', label: 'No Handoff'}); + }); + + it('filters out integrations without id', () => { + const integrations: CodingAgentIntegration[] = [ + {id: null, name: 'No Id', provider: 'other'}, + {id: '1', name: 'With Id', provider: 'cursor'}, + ]; + + const {result} = renderHookWithProviders(useAgentOptions, { + initialProps: {integrations}, + organization, + }); + + const options = result.current; + expect(options).toHaveLength(3); + expect(options[1]!.value).toMatchObject({id: '1', name: 'With Id'}); + }); + }); + + describe('useSelectedAgentFromProjectSettings', () => { + it('returns "none" when project autofixAutomationTuning is off', () => { + const p = ProjectFixture({...project, autofixAutomationTuning: 'off'}); + + const {result} = renderHookWithProviders(useSelectedAgentFromProjectSettings, { + initialProps: { + preference: {repositories: []}, + project: p, + integrations: [], + }, + organization, + }); + + expect(result.current).toBe('none'); + }); + + it('returns "seer" when no automation_handoff integration_id', () => { + const {result} = renderHookWithProviders(useSelectedAgentFromProjectSettings, { + initialProps: { + preference: {repositories: []}, + project, + integrations: [], + }, + organization, + }); + + expect(result.current).toBe('seer'); + }); + + it('returns matching integration when automation_handoff has integration_id', () => { + const integrations: CodingAgentIntegration[] = [ + {id: '99', name: 'Cursor', provider: 'cursor'}, + ]; + + const {result} = renderHookWithProviders(useSelectedAgentFromProjectSettings, { + initialProps: { + preference: { + repositories: [], + automation_handoff: { + handoff_point: 'root_cause', + target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, + integration_id: 99, + }, + }, + project, + integrations, + }, + organization, + }); + + expect(result.current).toMatchObject({id: '99', name: 'Cursor'}); + }); + }); + + describe('useSelectedAgentFromBulkSettings', () => { + it('returns "none" when autofixAutomationTuning is off', () => { + const {result} = renderHookWithProviders(useSelectedAgentFromBulkSettings, { + initialProps: { + autofixSettings: { + projectId: '1', + autofixAutomationTuning: 'off', + automatedRunStoppingPoint: undefined, + automationHandoff: undefined, + reposCount: 0, + }, + integrations: [], + }, + organization, + }); + + expect(result.current).toBe('none'); + }); + + it('returns "seer" when no automationHandoff integration_id', () => { + const {result} = renderHookWithProviders(useSelectedAgentFromBulkSettings, { + initialProps: { + autofixSettings: { + projectId: '1', + autofixAutomationTuning: 'medium', + automatedRunStoppingPoint: undefined, + automationHandoff: undefined, + reposCount: 0, + }, + integrations: [], + }, + organization, + }); + + expect(result.current).toBe('seer'); + }); + + it('returns matching integration when automationHandoff has integration_id', () => { + const integrations: CodingAgentIntegration[] = [ + {id: '99', name: 'Cursor', provider: 'cursor'}, + ]; + + const {result} = renderHookWithProviders(useSelectedAgentFromBulkSettings, { + initialProps: { + autofixSettings: { + projectId: '1', + autofixAutomationTuning: 'medium', + automatedRunStoppingPoint: undefined, + automationHandoff: { + handoff_point: 'root_cause', + target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, + integration_id: 99, + }, + reposCount: 0, + }, + integrations, + }, + organization, + }); + + expect(result.current).toMatchObject({id: '99', name: 'Cursor'}); + }); + }); + + describe('useMutateSelectedAgent', () => { + const basePreference: ProjectSeerPreferences = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + + const queryKey = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }).queryKey; + + function makeInitialCacheData() { + return { + pages: [ + { + json: [ + { + projectId: '1', + autofixAutomationTuning: 'off' as const, + automatedRunStoppingPoint: 'code_changes' as const, + automationHandoff: undefined, + reposCount: 2, + }, + { + projectId: '2', + autofixAutomationTuning: 'medium' as const, + automatedRunStoppingPoint: 'open_pr' as const, + automationHandoff: undefined, + reposCount: 1, + }, + ], + headers: { + Link: undefined, + 'X-Hits': undefined, + 'X-Max-Hits': undefined, + }, + }, + ], + pageParams: [undefined], + }; + } + + function setupMocks(preference: ProjectSeerPreferences = basePreference) { + const seerPreferencesGetRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + const projectPutRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + body: project, + }); + const seerPreferencesPostRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }); + return {seerPreferencesGetRequest, projectPutRequest, seerPreferencesPostRequest}; + } + + function renderMutateSelectedAgent() { + return renderHookWithProviders( + (props: {project: typeof project}) => { + const queryClient = useQueryClient(); + const mutate = useMutateSelectedAgent(props); + return {mutate, queryClient}; + }, + { + initialProps: {project}, + organization, + } + ); + } + + it('sends correct API requests when integration is "seer"', async () => { + const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {}); + }); + + await waitFor(() => { + expect(projectPutRequest).toHaveBeenCalledTimes(1); + }); + expect(projectPutRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: {autofixAutomationTuning: 'medium'}, + }) + ); + + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.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 when integration is "none"', async () => { + const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate('none', {}); + }); + + await waitFor(() => { + expect(projectPutRequest).toHaveBeenCalledTimes(1); + }); + expect(projectPutRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: {autofixAutomationTuning: 'off'}, + }) + ); + + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.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 when integration is a CodingAgentIntegration', async () => { + const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); + const integration: CodingAgentIntegration = { + id: '123', + name: 'Cursor', + provider: 'cursor', + }; + + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate(integration, {}); + }); + + await waitFor(() => { + expect(projectPutRequest).toHaveBeenCalledTimes(1); + }); + expect(projectPutRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: {autofixAutomationTuning: 'medium'}, + }) + ); + + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: false, + }, + }), + }) + ); + }); + + it('sets auto_create_pr from preference when integration is CodingAgentIntegration and stopping point is open_pr', async () => { + const {seerPreferencesPostRequest} = setupMocks({ + ...basePreference, + automated_run_stopping_point: 'open_pr', + }); + const integration: CodingAgentIntegration = { + id: '456', + name: 'Cursor', + provider: 'cursor', + }; + + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate(integration, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + }); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + integration_id: 456, + auto_create_pr: true, + }), + }), + }) + ); + }); + + it('passes through preference repositories and automated_run_stopping_point for all integration types', async () => { + const preferenceWithRepos: ProjectSeerPreferences = { + repositories: [ + { + external_id: 'repo-1', + name: 'my-repo', + owner: 'my-org', + provider: 'github', + }, + ], + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }; + + const {seerPreferencesPostRequest} = setupMocks(preferenceWithRepos); + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + }); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + repositories: [ + { + external_id: 'repo-1', + name: 'my-repo', + owner: 'my-org', + provider: 'github', + }, + ], + automated_run_stopping_point: 'open_pr', + }), + }) + ); + }); + + it('calls onSuccess when both requests succeed', async () => { + setupMocks(); + const onSuccess = jest.fn(); + + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {onSuccess}); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onError when a request fails', async () => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: basePreference, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + statusCode: 500, + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }); + const onError = jest.fn(); + + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {onError}); + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1); + }); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('optimistically updates the infinite query cache when selecting "seer"', () => { + setupMocks(); + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); + }); + + act(() => { + result.current.mutate('seer', {}); + }); + + const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< + typeof makeInitialCacheData + >; + expect(cached.pages[0]!.json[0]).toMatchObject({ + projectId: '1', + autofixAutomationTuning: 'medium', + automationHandoff: undefined, + }); + // Other project should not be affected + expect(cached.pages[0]!.json[1]).toMatchObject({ + projectId: '2', + autofixAutomationTuning: 'medium', + }); + }); + + it('optimistically updates the infinite query cache when selecting "none"', () => { + setupMocks(); + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); + }); + + act(() => { + result.current.mutate('none', {}); + }); + + const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< + typeof makeInitialCacheData + >; + expect(cached.pages[0]!.json[0]).toMatchObject({ + projectId: '1', + autofixAutomationTuning: 'off', + automationHandoff: undefined, + }); + }); + + it('optimistically updates the infinite query cache when selecting a CodingAgentIntegration', async () => { + setupMocks(); + const integration: CodingAgentIntegration = { + id: '123', + name: 'Cursor', + provider: 'cursor', + }; + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); + }); + + act(() => { + result.current.mutate(integration, {}); + }); + + await waitFor(() => { + const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< + typeof makeInitialCacheData + >; + expect(cached.pages[0]!.json[0]).toMatchObject({ + projectId: '1', + autofixAutomationTuning: 'medium', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: false, + }, + }); + }); + }); + + it('updates ProjectsStore when selecting "seer"', () => { + setupMocks(); + const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {}); + }); + + expect(storeSpy).toHaveBeenCalledWith({ + id: '1', + autofixAutomationTuning: 'medium', + }); + }); + + it('updates ProjectsStore when selecting "none"', () => { + setupMocks(); + const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + const {result} = renderMutateSelectedAgent(); + + act(() => { + result.current.mutate('none', {}); + }); + + expect(storeSpy).toHaveBeenCalledWith({ + id: '1', + autofixAutomationTuning: 'off', + }); + }); + }); + + describe('useMutateCreatePr', () => { + const basePreference: ProjectSeerPreferences = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + + const queryKey = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }).queryKey; + + function makeInitialCacheData() { + return { + pages: [ + { + json: [ + { + projectId: '1', + autofixAutomationTuning: 'medium' as const, + automatedRunStoppingPoint: + 'code_changes' as ProjectSeerPreferences['automated_run_stopping_point'], + automationHandoff: undefined, + reposCount: 2, + }, + ], + headers: { + Link: undefined, + 'X-Hits': undefined, + 'X-Max-Hits': undefined, + }, + }, + ], + pageParams: [undefined], + }; + } + + function setupMocks(preference: ProjectSeerPreferences = basePreference) { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + const seerPreferencesPostRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }); + return {seerPreferencesPostRequest}; + } + + function renderMutateCreatePr() { + return renderHookWithProviders( + (props: {project: typeof project}) => { + const queryClient = useQueryClient(); + const mutate = useMutateCreatePr(props); + return {mutate, queryClient}; + }, + { + initialProps: {project}, + organization, + } + ); + } + + describe('with seer agent', () => { + it('sends correct API request when enabling PR creation', async () => { + const {seerPreferencesPostRequest} = setupMocks(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate('seer', true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + }); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + repositories: [], + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }), + }) + ); + }); + + it('sends correct API request when disabling PR creation', async () => { + const {seerPreferencesPostRequest} = setupMocks(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate('seer', false, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + }); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automated_run_stopping_point: 'code_changes', + }), + }) + ); + }); + + it('optimistically updates the cache with automatedRunStoppingPoint', () => { + setupMocks(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); + }); + + act(() => { + result.current.mutate('seer', true, {}); + }); + + const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< + typeof makeInitialCacheData + >; + expect(cached.pages[0]!.json[0]).toMatchObject({ + projectId: '1', + automatedRunStoppingPoint: 'open_pr', + }); + }); + + it('optimistically updates the cache to code_changes when disabling', () => { + setupMocks(); + const initialData = makeInitialCacheData(); + initialData.pages[0]!.json[0]!.automatedRunStoppingPoint = 'open_pr' as const; + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.queryClient.setQueryData(queryKey, initialData); + }); + + act(() => { + result.current.mutate('seer', false, {}); + }); + + const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< + typeof makeInitialCacheData + >; + expect(cached.pages[0]!.json[0]).toMatchObject({ + automatedRunStoppingPoint: 'code_changes', + }); + }); + + it('does not update ProjectsStore (no tuning change)', () => { + setupMocks(); + const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate('seer', true, {}); + }); + + expect(storeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('with external agent (CodingAgentIntegration)', () => { + const integration: CodingAgentIntegration = { + id: '123', + name: 'Cursor', + provider: 'cursor', + }; + + it('sends correct API request when enabling PR creation', async () => { + const {seerPreferencesPostRequest} = setupMocks(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate(integration, true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + }); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: expect.objectContaining({ + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: true, + }), + }), + }) + ); + }); + + it('sends correct API request when disabling PR creation', async () => { + const {seerPreferencesPostRequest} = setupMocks(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate(integration, false, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + }); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + auto_create_pr: false, + }), + }), + }) + ); + }); + + it('optimistically updates the cache with automationHandoff', async () => { + setupMocks(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.queryClient.setQueryData(queryKey, makeInitialCacheData()); + }); + + act(() => { + result.current.mutate(integration, true, {}); + }); + + await waitFor(() => { + const cached = result.current.queryClient.getQueryData(queryKey) as ReturnType< + typeof makeInitialCacheData + >; + expect(cached.pages[0]!.json[0]).toMatchObject({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: true, + }, + }); + }); + }); + + it('does not update ProjectsStore (no tuning change)', () => { + setupMocks(); + const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate(integration, true, {}); + }); + + expect(storeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('with "none" agent', () => { + it('does not make any API calls', () => { + const {seerPreferencesPostRequest} = setupMocks(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate('none', true, {}); + }); + + expect(seerPreferencesPostRequest).not.toHaveBeenCalled(); + }); + }); + + it('calls onSuccess when the request succeeds', async () => { + setupMocks(); + const onSuccess = jest.fn(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate('seer', true, {onSuccess}); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onError when the request fails', async () => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: basePreference, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + statusCode: 500, + body: {}, + }); + const onError = jest.fn(); + const {result} = renderMutateCreatePr(); + + act(() => { + result.current.mutate('seer', true, {onError}); + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1); + }); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); +}); diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx new file mode 100644 index 00000000000000..4c19a50cf6410d --- /dev/null +++ b/static/app/views/settings/seer/seerAgentHooks.tsx @@ -0,0 +1,260 @@ +import {useCallback, useMemo} from 'react'; + +import { + bulkAutofixAutomationSettingsInfiniteOptions, + type AutofixAutomationSettings, +} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import { + useFetchProjectSeerPreferences, + useUpdateProjectSeerPreferences, +} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; +import {PROVIDER_TO_HANDOFF_TARGET} from 'sentry/components/events/autofix/types'; +import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; +import {type CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +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 {useOrganization} from 'sentry/utils/useOrganization'; + +export function useAgentOptions({ + integrations, +}: { + integrations: CodingAgentIntegration[]; +}) { + return useMemo(() => { + return [ + {value: 'seer' as const, label: t('Seer Agent')}, + ...integrations + .filter(integration => integration.id) + .map(integration => ({ + value: integration, + label: integration.name, + })), + {value: 'none' as const, label: t('No Handoff')}, + ]; + }, [integrations]); +} + +export function useSelectedAgentFromProjectSettings({ + integrations, + preference, + project, +}: { + integrations: CodingAgentIntegration[]; + preference: ProjectSeerPreferences; + project: Project; +}) { + return useMemo(() => { + // If we have autofixAutomationTuning==OFF then 'none' is picked + if (project.autofixAutomationTuning === 'off') { + return 'none'; + } + // If we have nothing in preferences, then we have Seer + if (!preference?.automation_handoff?.integration_id) { + return 'seer'; + } + // Otherwise, we have a preference! + return integrations.find( + integration => + integration.id === String(preference.automation_handoff?.integration_id) + ); + }, [ + preference?.automation_handoff?.integration_id, + project.autofixAutomationTuning, + integrations, + ]); +} + +export function useSelectedAgentFromBulkSettings({ + autofixSettings, + integrations, +}: { + autofixSettings: AutofixAutomationSettings; + integrations: CodingAgentIntegration[]; +}) { + return useMemo(() => { + // If we have autofixAutomationTuning==OFF then 'none' is picked + if (autofixSettings.autofixAutomationTuning === 'off') { + return 'none'; + } + // If we have nothing in preferences, then we have Seer + if (!autofixSettings?.automationHandoff?.integration_id) { + return 'seer'; + } + // Otherwise, we have a preference! + return integrations.find( + integration => + integration.id === String(autofixSettings.automationHandoff?.integration_id) + ); + }, [ + autofixSettings.automationHandoff?.integration_id, + autofixSettings.autofixAutomationTuning, + integrations, + ]); +} + +type MutateOptions = { + onError?: (error: Error) => void; + onSuccess?: () => void; +}; + +function useApplyOptimisticUpdate({project}: {project: Project}) { + const queryClient = useQueryClient(); + const organization = useOrganization(); + const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }); + + return useCallback( + (updates: Partial) => { + queryClient.setQueryData(autofixSettingsQueryOptions.queryKey, oldData => { + if (!oldData) { + return oldData; + } + return { + ...oldData, + pages: oldData.pages.map(page => ({ + ...page, + json: page.json.map(setting => + String(setting.projectId) === project.id + ? {...setting, ...updates} + : setting + ), + })), + }; + }); + + if ( + updates.autofixAutomationTuning !== undefined && + updates.autofixAutomationTuning !== null + ) { + ProjectsStore.onUpdateSuccess({ + id: project.id, + autofixAutomationTuning: updates.autofixAutomationTuning, + }); + } + }, + [queryClient, autofixSettingsQueryOptions.queryKey, project.id] + ); +} + +export function useMutateSelectedAgent({project}: {project: Project}) { + const {mutateAsync: updateProject} = useUpdateProject(project); + const {mutateAsync: updateProjectSeerPreferences} = + useUpdateProjectSeerPreferences(project); + const applyOptimisticUpdate = useApplyOptimisticUpdate({project}); + const fetchPreferences = useFetchProjectSeerPreferences({project}); + + return useCallback( + ( + integration: 'seer' | 'none' | CodingAgentIntegration, + {onSuccess, onError}: MutateOptions + ) => { + if (integration === 'seer' || integration === 'none') { + const tuning = integration === 'seer' ? 'medium' : 'off'; + applyOptimisticUpdate({ + autofixAutomationTuning: tuning, + automationHandoff: undefined, + }); + + fetchPreferences() + .then(preference => + Promise.all([ + updateProject({autofixAutomationTuning: tuning}), + updateProjectSeerPreferences({ + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: undefined, + }), + ]) + ) + .then(() => onSuccess?.()) + .catch(() => onError?.(new Error('Failed to update agent setting'))); + } else { + applyOptimisticUpdate({ + autofixAutomationTuning: 'medium', + }); + + fetchPreferences() + .then(preference => { + const handoff: ProjectSeerPreferences['automation_handoff'] = 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; + + applyOptimisticUpdate({ + automationHandoff: handoff, + }); + + return Promise.all([ + updateProject({autofixAutomationTuning: 'medium'}), + updateProjectSeerPreferences({ + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: handoff, + }), + ]); + }) + .then(() => onSuccess?.()) + .catch(() => onError?.(new Error('Failed to update agent setting'))); + } + }, + [updateProject, updateProjectSeerPreferences, applyOptimisticUpdate, fetchPreferences] + ); +} + +export function useMutateCreatePr({project}: {project: Project}) { + const {mutateAsync: updateProjectSeerPreferences} = + useUpdateProjectSeerPreferences(project); + const applyOptimisticUpdate = useApplyOptimisticUpdate({project}); + const fetchPreferences = useFetchProjectSeerPreferences({project}); + + return useCallback( + ( + autofixAgent: 'seer' | 'none' | CodingAgentIntegration | undefined, + value: boolean, + {onSuccess, onError}: MutateOptions + ) => { + if (autofixAgent === 'seer') { + const stoppingPoint = value ? ('open_pr' as const) : ('code_changes' as const); + applyOptimisticUpdate({automatedRunStoppingPoint: stoppingPoint}); + fetchPreferences() + .then(preference => + updateProjectSeerPreferences({ + repositories: preference?.repositories ?? [], + automated_run_stopping_point: stoppingPoint, + automation_handoff: preference?.automation_handoff, + }) + ) + .then(() => onSuccess?.()) + .catch(() => onError?.(new Error('Failed to update PR setting'))); + } else if (autofixAgent && autofixAgent !== 'none') { + fetchPreferences() + .then(preference => { + const updatedHandoff = { + handoff_point: 'root_cause' as const, + integration_id: Number(autofixAgent.id), + ...preference?.automation_handoff, + target: PROVIDER_TO_HANDOFF_TARGET[autofixAgent.provider]!, + auto_create_pr: value, + }; + applyOptimisticUpdate({automationHandoff: updatedHandoff}); + return updateProjectSeerPreferences({ + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: updatedHandoff, + }); + }) + .then(() => onSuccess?.()) + .catch(() => onError?.(new Error('Failed to update PR setting'))); + } + }, + [updateProjectSeerPreferences, applyOptimisticUpdate, fetchPreferences] + ); +} diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx index e75f4da5f0438a..16cb160a4970e6 100644 --- a/static/gsApp/views/seerAutomation/settings.tsx +++ b/static/gsApp/views/seerAutomation/settings.tsx @@ -1,6 +1,5 @@ -import {Alert} from '@sentry/scraps/alert'; -import {Flex, Grid, Stack} from '@sentry/scraps/layout'; -import {ExternalLink, Link} from '@sentry/scraps/link'; +import {Flex, Grid} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; import {Form} from 'sentry/components/forms/form'; import JsonForm from 'sentry/components/forms/jsonForm'; @@ -8,14 +7,11 @@ import {QuestionTooltip} from 'sentry/components/questionTooltip'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; 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 { - 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 {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'; @@ -48,7 +44,7 @@ export function SeerAutomationSettings() { )} /> - + @@ -76,119 +72,119 @@ export function SeerAutomationSettings() { - {t('Default automations for new projects')} - - ), - } - )} - size="xs" - icon="info" - /> - - ), - fields: [ - { - name: 'defaultAutofixAutomationTuning', - label: t('Auto-Trigger Fixes by Default'), - help: t( - 'For all new projects, Seer will automatically create a root cause analysis for highly actionable issues and propose a solution without a user needing to prompt it.' - ), - type: 'boolean', - // Convert from between enum and boolean - // All values other than 'off' are converted to 'medium' - setValue: ( - value: Organization['defaultAutofixAutomationTuning'] - ): boolean => Boolean(value && value !== 'off'), - getValue: ( - value: boolean - ): Organization['defaultAutofixAutomationTuning'] => - value ? 'medium' : 'off', - }, - { - name: 'autoOpenPrs', - label: t('Allow Root Cause Analysis to create PRs by Default'), - help: ( - - {t( - 'For all new projects with connected repos, Seer will be able to make pull requests for highly actionable issues.' - )} - {organization.enableSeerCoding === false && ( - - {tct( - '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.', - { - settings: ( - - ), - } - )} - - )} - - ), - type: 'boolean', - disabled: !canWrite || organization.enableSeerCoding === false, - setValue: (value: boolean): boolean => - organization.enableSeerCoding === false ? false : value, - }, - ], - }, - { - title: ( - - {t('Default Code Review for New Repos')} - - ), - } - )} - size="xs" - icon="info" - /> - - ), - fields: [ - { - name: 'autoEnableCodeReview', - label: t('Enable Code Review by Default'), - help: t( - 'For all new repos connected, Seer will review your PRs and flag potential bugs.' - ), - type: 'boolean', - }, - { - name: 'defaultCodeReviewTriggers', - label: t('Code Review Triggers'), - help: tct( - 'Reviews can always run on demand by calling [code:@sentry review], whenever a PR is opened, or after each commit is pushed to a PR.', - {code: } - ), - type: 'choice', - multiple: true, - choices: [ - ['on_ready_for_review', t('On Ready for Review')], - ['on_new_commit', t('On New Commit')], - ], - formatMessageValue: false, - }, - ], - }, + // { + // title: ( + // + // {t('Default automations for new projects')} + // + // ), + // } + // )} + // size="xs" + // icon="info" + // /> + // + // ), + // fields: [ + // { + // name: 'defaultAutofixAutomationTuning', + // label: t('Auto-Trigger Fixes by Default'), + // help: t( + // 'For all new projects, Seer will automatically create a root cause analysis for highly actionable issues and propose a solution without a user needing to prompt it.' + // ), + // type: 'boolean', + // // Convert from between enum and boolean + // // All values other than 'off' are converted to 'medium' + // setValue: ( + // value: Organization['defaultAutofixAutomationTuning'] + // ): boolean => Boolean(value && value !== 'off'), + // getValue: ( + // value: boolean + // ): Organization['defaultAutofixAutomationTuning'] => + // value ? 'medium' : 'off', + // }, + // { + // name: 'autoOpenPrs', + // label: t('Allow Root Cause Analysis to create PRs by Default'), + // help: ( + // + // {t( + // 'For all new projects with connected repos, Seer will be able to make pull requests for highly actionable issues.' + // )} + // {organization.enableSeerCoding === false && ( + // + // {tct( + // '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.', + // { + // settings: ( + // + // ), + // } + // )} + // + // )} + // + // ), + // type: 'boolean', + // disabled: !canWrite || organization.enableSeerCoding === false, + // setValue: (value: boolean): boolean => + // organization.enableSeerCoding === false ? false : value, + // }, + // ], + // }, + // { + // title: ( + // + // {t('Default Code Review for New Repos')} + // + // ), + // } + // )} + // size="xs" + // icon="info" + // /> + // + // ), + // fields: [ + // { + // name: 'autoEnableCodeReview', + // label: t('Enable Code Review by Default'), + // help: t( + // 'For all new repos connected, Seer will review your PRs and flag potential bugs.' + // ), + // type: 'boolean', + // }, + // { + // name: 'defaultCodeReviewTriggers', + // label: t('Code Review Triggers'), + // help: tct( + // 'Reviews can always run on demand by calling [code:@sentry review], whenever a PR is opened, or after each commit is pushed to a PR.', + // {code: } + // ), + // type: 'choice', + // multiple: true, + // choices: [ + // ['on_ready_for_review', t('On Ready for Review')], + // ['on_new_commit', t('On New Commit')], + // ], + // formatMessageValue: false, + // }, + // ], + // }, { title: t('Advanced Settings'), fields: [