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/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/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/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 new file mode 100644 index 00000000000000..1a3b685526a9de --- /dev/null +++ b/static/app/views/settings/seer/overview/seerOverview.stories.tsx @@ -0,0 +1,172 @@ +import * as Storybook from 'sentry/stories'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; +import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection'; +import {CodeReviewOverviewSection} from 'sentry/views/settings/seer/overview/codeReviewOverviewSection'; +import {SeerOverview} from 'sentry/views/settings/seer/overview/components'; +import {SCMOverviewSection} from 'sentry/views/settings/seer/overview/scmOverviewSection'; +import type {useSeerOverviewData} from 'sentry/views/settings/seer/overview/useSeerOverviewData'; + +function OrganizationIntegrationsFixture( + params: Partial = {} +): OrganizationIntegration { + return { + accountType: '', + gracePeriodEnd: '', + organizationIntegrationStatus: 'active', + domainName: 'github.com', + icon: 'https://secure.gravatar.com/avatar/8b4cb68e40b74c90427d8262256bd1c8', + id: '5', + name: 'NisanthanNanthakumar', + provider: { + aspects: {}, + canAdd: true, + canDisable: false, + features: ['commits', 'issue-basic'], + key: 'github', + name: 'Github', + slug: 'github', + }, + status: 'active', + configData: null, + configOrganization: [], + externalId: 'ext-integration-1', + organizationId: '1', + ...params, + }; +} + +const seerIntegrationsFixture = [ + OrganizationIntegrationsFixture({id: '1', name: 'Integration A'}), + OrganizationIntegrationsFixture({id: '2', name: 'Integration B'}), +]; + +const baseStats: ReturnType['stats'] = { + integrationCount: 2, + scmIntegrationCount: 2, + seerIntegrations: seerIntegrationsFixture, + seerIntegrationCount: 2, + totalRepoCount: 10, + seerRepoCount: 10, // equal to totalRepoCount: no "Add all repos" button + reposWithSettingsCount: 10, + projectsWithReposCount: 6, // equal to totalProjects: no "Handoff all to" CompactSelect + projectsWithAutomationCount: 6, + projectsWithCreatePrCount: 6, + totalProjects: 6, + reposWithCodeReviewCount: 10, // equal to seerRepoCount +}; + +function TestableOverview({ + stats, + isLoading, +}: { + isLoading: boolean; + stats: ReturnType['stats']; +}) { + return ( + + + + + + ); +} + +export default Storybook.story('SeerOverview', story => { + story('No alerts (healthy state)', () => ( + + )); + + story('Loading state', () => ); + + // SCM stories + + story('SCM: No SCM integrations installed', () => ( + + )); + + story('SCM: Integrations installed but no repos connected', () => ( + + )); + + story('SCM: Some repos not yet added to Seer', () => ( + + )); + + // Autofix stories + + story('Autofix: No projects have repos linked', () => ( + + )); + + story('Autofix: Some projects with repos (partial)', () => ( + + )); + + // Code Review stories + + story('Code Review: No repos have code review enabled', () => ( + 0 → ButtonBar visible, shows 0/10 + }} + isLoading={false} + /> + )); +}); diff --git a/static/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/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx similarity index 99% rename from static/gsApp/views/seerAutomation/components/seerAgentHooks.spec.tsx rename to static/app/views/settings/seer/seerAgentHooks.spec.tsx index 3e91088678eda8..d7a0f5eaedb191 100644 --- a/static/gsApp/views/seerAutomation/components/seerAgentHooks.spec.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx @@ -12,14 +12,13 @@ import { 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'; +} from 'sentry/views/settings/seer/seerAgentHooks'; describe('seerAgentHooks', () => { const organization = OrganizationFixture({slug: 'org-slug'}); diff --git a/static/gsApp/views/seerAutomation/components/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx similarity index 100% rename from static/gsApp/views/seerAutomation/components/seerAgentHooks.tsx rename to static/app/views/settings/seer/seerAgentHooks.tsx 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/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx index 08bf20cbfa54d8..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, 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,9 +7,12 @@ 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} 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'; import {SeerSettingsPageWrapper} from 'getsentry/views/seerAutomation/components/seerSettingsPageWrapper'; @@ -19,6 +21,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 +32,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 +44,11 @@ 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 Autofix 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: [ @@ -196,7 +204,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: ( - + ), } )}