diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 41e942fbc7cd70..75e586ab5d82e3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -866,6 +866,10 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /tests/sentry/scm/ @getsentry/scm ## End of SCM +## SCM Frontend +/static/app/components/repositories @getsentry/coding-workflows-sentry-frontend @getsentry/scm +## End of SCM Frontend + # End of Coding Workflows # Conduit diff --git a/static/gsApp/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings.tsx b/static/app/components/repositories/useBulkUpdateRepositorySettings.tsx similarity index 92% rename from static/gsApp/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings.tsx rename to static/app/components/repositories/useBulkUpdateRepositorySettings.tsx index cd704672510560..114813e0f6c2dc 100644 --- a/static/gsApp/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings.tsx +++ b/static/app/components/repositories/useBulkUpdateRepositorySettings.tsx @@ -1,3 +1,4 @@ +import {getRepositoryWithSettingsQueryKey} from 'sentry/components/repositories/useRepositoryWithSettings'; import type {RepositoryWithSettings} from 'sentry/types/integrations'; import { fetchMutation, @@ -7,8 +8,6 @@ import { } from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {getRepositoryWithSettingsQueryKey} from 'getsentry/views/seerAutomation/onboarding/hooks/useRepositoryWithSettings'; - export type RepositorySettings = | { enabledCodeReview: boolean; diff --git a/static/gsApp/views/seerAutomation/onboarding/hooks/useRepositoryWithSettings.tsx b/static/app/components/repositories/useRepositoryWithSettings.tsx similarity index 100% rename from static/gsApp/views/seerAutomation/onboarding/hooks/useRepositoryWithSettings.tsx rename to static/app/components/repositories/useRepositoryWithSettings.tsx diff --git a/static/app/views/settings/seer/overview/codeReviewOverviewSection.stories.tsx b/static/app/views/settings/seer/overview/codeReviewOverviewSection.stories.tsx new file mode 100644 index 00000000000000..db2e47637cddfa --- /dev/null +++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.stories.tsx @@ -0,0 +1,137 @@ +import {Fragment, type ComponentProps} from 'react'; + +import * as Storybook from 'sentry/stories'; +import {RepositoryStatus} from 'sentry/types/integrations'; +import type {RepositoryWithSettings} from 'sentry/types/integrations'; +import type {Organization} from 'sentry/types/organization'; +import {CodeReviewOverviewSection} from 'sentry/views/settings/seer/overview/codeReviewOverviewSection'; + +type Props = ComponentProps; + +function makeRepo( + id: string, + name: string, + enabledCodeReview: boolean +): RepositoryWithSettings { + return { + id, + name, + externalId: id, + externalSlug: name, + integrationId: '1', + url: `https://github.com/${name}`, + status: RepositoryStatus.ACTIVE, + dateCreated: '', + provider: {id: 'integrations:github', name: 'GitHub'}, + settings: { + enabledCodeReview, + codeReviewTriggers: ['on_ready_for_review', 'on_new_commit'], + }, + }; +} + +const ALL_REPOS: RepositoryWithSettings[] = [ + makeRepo('1', 'my-org/frontend', false), + makeRepo('2', 'my-org/backend', false), + makeRepo('3', 'my-org/infra', false), + makeRepo('4', 'my-org/mobile', false), + makeRepo('5', 'my-org/data-pipeline', false), +]; + +const BASE_ORG = { + slug: 'my-org', + autoEnableCodeReview: true, + defaultCodeReviewTriggers: ['on_ready_for_review', 'on_new_commit'], + access: ['org:read', 'org:write', 'org:admin', 'org:integrations'], +} as Organization; + +const ORG = BASE_ORG; +const ORG_AUTO_OFF: Organization = {...BASE_ORG, autoEnableCodeReview: false}; +const ORG_READONLY: Organization = {...BASE_ORG, access: ['org:read']}; + +function makeProps( + seerRepos: RepositoryWithSettings[], + reposWithCodeReview: RepositoryWithSettings[], + orgOverride: Organization = ORG, + isPending = false +): Props { + return { + isPending, + data: {queryKey: ['repositories'] as any, seerRepos, reposWithCodeReview}, + refetch: () => Promise.resolve({} as any), + canWrite: true, + organization: orgOverride, + } as Props; +} + +export default Storybook.story('CodeReviewOverviewSection', story => { + story('Overview', () => ( + +

+ The is the code review + panel in the Seer settings overview page. It lets users enable or disable code + review across all existing repositories at once and configure the default triggers + (on push vs. on ready-for-review). +

+

+ The component derives its display from two lists: seerRepos (all + Seer-compatible repos) and reposWithCodeReview (the subset that have + code review enabled). The ratio between them drives the counter text and the + enable/disable bulk-action button state. The{' '} + Enable Code Review by Default toggle reflects{' '} + organization.autoEnableCodeReview and flips the direction of the bulk + action (enable remaining vs. disable all). +

+
+ )); + + story('Loading', () => ); + + story('0 repos', () => ); + + story('1 repo / 0 enabled', () => ( + + )); + + story('1 repo / 1 enabled (all)', () => ( + + )); + + story('N repos / 0 enabled', () => ( + + )); + + story('N repos / 1 enabled', () => ( + + )); + + story('N repos / many enabled (not all)', () => ( + + )); + + story('N repos / all enabled', () => ( + + )); + + story('Auto-enable off / 0 enabled', () => ( + + )); + + story('Auto-enable off / some enabled', () => ( + + )); + + story('Auto-enable off / all enabled', () => ( + + )); + + story('Read-only (no org:write)', () => ( + + )); +}); 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..d3e2e643345e2c --- /dev/null +++ b/static/app/views/settings/seer/overview/codeReviewOverviewSection.tsx @@ -0,0 +1,315 @@ +import {useCallback} from 'react'; +import {mutationOptions} from '@tanstack/react-query'; +import uniqBy from 'lodash/uniqBy'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Link} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; + +import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {updateOrganization} from 'sentry/actionCreators/organizations'; +import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories'; +import {useSeerSupportedProviderIds} from 'sentry/components/events/autofix/utils'; +import {useBulkUpdateRepositorySettings} from 'sentry/components/repositories/useBulkUpdateRepositorySettings'; +import {getRepositoryWithSettingsQueryKey} from 'sentry/components/repositories/useRepositoryWithSettings'; +import {IconRefresh, 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 {useFetchAllPages} from 'sentry/utils/api/apiFetch'; +import {useInfiniteQuery, useQueryClient} from 'sentry/utils/queryClient'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +export function useCodeReviewOverviewSection() { + const organization = useOrganization(); + const seerSupportedProviderIds = useSeerSupportedProviderIds(); + + const queryOptions = organizationRepositoriesInfiniteOptions({ + organization, + query: {per_page: 100}, + }); + const repositoriesResult = useInfiniteQuery({ + ...queryOptions, + select: ({pages}) => { + const repos = uniqBy( + pages.flatMap(page => page.json), + 'externalId' + ).filter(repository => repository.externalId); + const seerRepos = repos.filter(r => + seerSupportedProviderIds.includes(r.provider.id) + ); + const reposWithCodeReview = seerRepos.filter(r => r.settings?.enabledCodeReview); + return { + queryKey: queryOptions.queryKey, + seerRepos, + reposWithCodeReview, + }; + }, + }); + useFetchAllPages({result: repositoriesResult}); + + return repositoriesResult; +} + +type Props = ReturnType & { + canWrite: boolean; + organization: Organization; +}; + +export function CodeReviewOverviewSection({ + canWrite, + isPending, + organization, + data, + refetch, +}: Props) { + const queryClient = useQueryClient(); + + const {queryKey, seerRepos = [], reposWithCodeReview = []} = data ?? {}; + + const seerReposCount = seerRepos.length; + const reposWithCodeReviewCount = reposWithCodeReview.length; + + const schema = z.object({ + autoEnableCodeReview: z.boolean(), + defaultCodeReviewTriggers: z.array(z.enum(['on_new_commit', 'on_ready_for_review'])), + }); + + const orgMutationOpts = mutationOptions({ + mutationFn: (updateData: Partial) => + fetchMutation({ + method: 'PUT', + url: `/organizations/${organization.slug}/`, + data: updateData, + }), + onSuccess: updateOrganization, + }); + + const {mutate: mutateRepositorySettings} = useBulkUpdateRepositorySettings({ + onSettled: mutations => { + // Invalidate the repositories query to get the updated settings + if (queryKey) { + queryClient.invalidateQueries({queryKey}); + } + (mutations ?? []).forEach(mutation => { + // Invalidate related queries + queryClient.invalidateQueries({ + queryKey: getRepositoryWithSettingsQueryKey(organization, mutation.id), + }); + }); + }, + }); + + const handleToggleCodeReview = useCallback( + (enabledCodeReview: boolean) => { + const repositoryIds = ( + enabledCodeReview + ? seerRepos.filter(repo => !repo.settings?.enabledCodeReview) + : reposWithCodeReview + ).map(repo => repo.id); + mutateRepositorySettings( + {enabledCodeReview, repositoryIds}, + { + onError: (_, variables) => { + addErrorMessage( + tn( + 'Failed to update code review for %s repository', + 'Failed to update code review for %s repositories', + variables.repositoryIds.length + ) + ); + }, + onSuccess: (_, variables) => { + addSuccessMessage( + tn( + 'Code review updated for %s repository', + 'Code review updated for %s repositories', + variables.repositoryIds.length + ) + ); + }, + } + ); + }, + [mutateRepositorySettings, reposWithCodeReview, seerRepos] + ); + + const handleChangeTriggers = useCallback( + (newTriggers: string[]) => { + mutateRepositorySettings( + { + codeReviewTriggers: newTriggers, + repositoryIds: seerRepos.map(repo => repo.id), + }, + { + onError: (_, variables) => { + addErrorMessage( + tn( + 'Failed to update triggers for %s repository', + 'Failed to update triggers for %s repositories', + variables.repositoryIds.length + ) + ); + }, + onSuccess: (_, variables) => { + addSuccessMessage( + tn( + 'Triggers updated for %s repository', + 'Triggers updated for %s repositories', + variables.repositoryIds.length + ) + ); + }, + } + ); + }, + [mutateRepositorySettings, seerRepos] + ); + + return ( + + + {t('Code Review')} + + + {field.state.value + ? t( + '%s of %s existing repos have code review enabled', + reposWithCodeReviewCount, + seerReposCount + ) + : t( + '%s of %s existing repos have code review disabled', + seerReposCount - reposWithCodeReviewCount, + seerReposCount + )} + + + + )} + + + + {field => ( + + } + )} + > + + + + + + + + + )} + + + ); +} diff --git a/static/gsApp/views/seerAutomation/components/repoDetails/repoDetailsForm.tsx b/static/gsApp/views/seerAutomation/components/repoDetails/repoDetailsForm.tsx index 36d787f8fba4e1..661b2a7301d171 100644 --- a/static/gsApp/views/seerAutomation/components/repoDetails/repoDetailsForm.tsx +++ b/static/gsApp/views/seerAutomation/components/repoDetails/repoDetailsForm.tsx @@ -3,12 +3,12 @@ import {Stack} from '@sentry/scraps/layout'; import {Form} from 'sentry/components/forms/form'; import JsonForm from 'sentry/components/forms/jsonForm'; +import {type RepositorySettings} from 'sentry/components/repositories/useBulkUpdateRepositorySettings'; import {t, tct} from 'sentry/locale'; import {type RepositoryWithSettings} from 'sentry/types/integrations'; import type {Organization} from 'sentry/types/organization'; import {useCanWriteSettings} from 'getsentry/views/seerAutomation/components/useCanWriteSettings'; -import {type RepositorySettings} from 'getsentry/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings'; interface Props { organization: Organization; diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx index 4e065e988b1a4d..87ea64f66ada6d 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx @@ -20,6 +20,8 @@ import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal'; +import {useBulkUpdateRepositorySettings} from 'sentry/components/repositories/useBulkUpdateRepositorySettings'; +import {getRepositoryWithSettingsQueryKey} from 'sentry/components/repositories/useRepositoryWithSettings'; import {IconAdd} from 'sentry/icons'; import {IconSearch} from 'sentry/icons/iconSearch'; import {t, tct} from 'sentry/locale'; @@ -35,8 +37,6 @@ import {useOrganization} from 'sentry/utils/useOrganization'; import {SeerRepoTableHeader} from 'getsentry/views/seerAutomation/components/repoTable/seerRepoTableHeader'; import {SeerRepoTableRow} from 'getsentry/views/seerAutomation/components/repoTable/seerRepoTableRow'; -import {useBulkUpdateRepositorySettings} from 'getsentry/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings'; -import {getRepositoryWithSettingsQueryKey} from 'getsentry/views/seerAutomation/onboarding/hooks/useRepositoryWithSettings'; const GRID_COLUMNS = '40px 1fr 118px 150px'; const SELECTED_ROW_HEIGHT = 44; diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx index 54464c61b68585..2154026373c489 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx @@ -8,6 +8,7 @@ import {Flex} from '@sentry/scraps/layout'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import {QuestionTooltip} from 'sentry/components/questionTooltip'; +import type {useBulkUpdateRepositorySettings} from 'sentry/components/repositories/useBulkUpdateRepositorySettings'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {t, tct, tn} from 'sentry/locale'; import {parseQueryKey} from 'sentry/utils/api/apiQueryKey'; @@ -15,7 +16,6 @@ import type {Sort} from 'sentry/utils/discover/fields'; import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState'; import {useCanWriteSettings} from 'getsentry/views/seerAutomation/components/useCanWriteSettings'; -import type {useBulkUpdateRepositorySettings} from 'getsentry/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings'; interface Props { gridColumns: string; diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableRow.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableRow.tsx index ecca8455caf784..0208f6e6de1bd7 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableRow.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableRow.tsx @@ -12,6 +12,11 @@ import { addSuccessMessage, } from 'sentry/actionCreators/indicator'; import {getRepoStatusLabel} from 'sentry/components/repositories/getRepoStatusLabel'; +import {useBulkUpdateRepositorySettings} from 'sentry/components/repositories/useBulkUpdateRepositorySettings'; +import { + getRepositoryWithSettingsQueryKey, + useRepositoryWithSettings, +} from 'sentry/components/repositories/useRepositoryWithSettings'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {IconOpen} from 'sentry/icons/iconOpen'; import {t} from 'sentry/locale'; @@ -26,11 +31,6 @@ import {useQueryClient} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useCanWriteSettings} from 'getsentry/views/seerAutomation/components/useCanWriteSettings'; -import {useBulkUpdateRepositorySettings} from 'getsentry/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings'; -import { - getRepositoryWithSettingsQueryKey, - useRepositoryWithSettings, -} from 'getsentry/views/seerAutomation/onboarding/hooks/useRepositoryWithSettings'; interface Props { gridColumns: string; diff --git a/static/gsApp/views/seerAutomation/onboarding/configureCodeReviewStep.tsx b/static/gsApp/views/seerAutomation/onboarding/configureCodeReviewStep.tsx index 9aca9f09218163..abd5295a726169 100644 --- a/static/gsApp/views/seerAutomation/onboarding/configureCodeReviewStep.tsx +++ b/static/gsApp/views/seerAutomation/onboarding/configureCodeReviewStep.tsx @@ -16,13 +16,13 @@ import { } from 'sentry/components/guidedSteps/guidedSteps'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {PanelBody} from 'sentry/components/panels/panelBody'; +import {useBulkUpdateRepositorySettings} from 'sentry/components/repositories/useBulkUpdateRepositorySettings'; import {t} from 'sentry/locale'; import {DEFAULT_CODE_REVIEW_TRIGGERS} from 'sentry/types/integrations'; import {useOrganization} from 'sentry/utils/useOrganization'; import {trackGetsentryAnalytics} from 'getsentry/utils/trackGetsentryAnalytics'; import {useSeerOnboardingContext} from 'getsentry/views/seerAutomation/onboarding/hooks/seerOnboardingContext'; -import {useBulkUpdateRepositorySettings} from 'getsentry/views/seerAutomation/onboarding/hooks/useBulkUpdateRepositorySettings'; import {MaxWidthPanel, PanelDescription, StepContent} from './common'; import {RepositorySelector} from './repositorySelector'; diff --git a/static/gsApp/views/seerAutomation/repoDetails.tsx b/static/gsApp/views/seerAutomation/repoDetails.tsx index 1afc2c2079e653..c25218d84f9fb9 100644 --- a/static/gsApp/views/seerAutomation/repoDetails.tsx +++ b/static/gsApp/views/seerAutomation/repoDetails.tsx @@ -9,6 +9,7 @@ import {useIsSeerSupportedProvider} from 'sentry/components/events/autofix/utils import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {RepoProviderIcon} from 'sentry/components/repositories/repoProviderIcon'; +import {useRepositoryWithSettings} from 'sentry/components/repositories/useRepositoryWithSettings'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -17,7 +18,6 @@ import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageH import {RepoDetailsForm} from 'getsentry/views/seerAutomation/components/repoDetails/repoDetailsForm'; import {SeerSettingsPageWrapper} from 'getsentry/views/seerAutomation/components/seerSettingsPageWrapper'; -import {useRepositoryWithSettings} from 'getsentry/views/seerAutomation/onboarding/hooks/useRepositoryWithSettings'; export default function SeerRepoDetails() { const {repoId} = useParams<{repoId: string}>(); diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx index e8c9881fd286f4..d2ba78a3f63d42 100644 --- a/static/gsApp/views/seerAutomation/settings.tsx +++ b/static/gsApp/views/seerAutomation/settings.tsx @@ -15,6 +15,10 @@ import type {Organization} from 'sentry/types/organization'; import {fetchMutation} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; +import { + CodeReviewOverviewSection, + useCodeReviewOverviewSection, +} from 'sentry/views/settings/seer/overview/codeReviewOverviewSection'; import { SCMOverviewSection, useSCMOverviewSection, @@ -37,7 +41,10 @@ export function SeerAutomationSettings() { const organization = useOrganization(); const canWrite = useCanWriteSettings(); + const showSeerOverview = organization.features.includes('seer-overview'); + const scmOverviewData = useSCMOverviewSection(); + const codeReviewOverviewData = useCodeReviewOverviewSection(); const orgEndpoint = `/organizations/${organization.slug}/`; const orgMutationOpts = mutationOptions({ @@ -82,9 +89,9 @@ export function SeerAutomationSettings() { /> - - {t('Default Code Review for New Repos')} - , - } - )} - size="xs" - icon="info" - /> - - } - > - - {field => ( - - + ) : ( + + {t('Default Code Review for New Repos')} + , + } + )} + size="xs" + icon="info" /> - - )} - - } - mutationOptions={orgMutationOpts} > - {field => ( - } - )} - > - - - )} - - + + {field => ( + + + + )} + + + {field => ( + } + )} + > + + + )} + + + )}