From 6eae0f898c198c54383bd922c508a43659fbb12f Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 15 Apr 2026 14:02:58 -0700 Subject: [PATCH 1/4] feat(seer): Allow bulk-editing Code Review triggers --- .../useBulkUpdateRepositorySettings.tsx | 3 +- .../components/repoTable/seerRepoTable.tsx | 54 ++-- .../repoTable/seerRepoTableHeader.tsx | 257 +++++++++++++++--- 3 files changed, 247 insertions(+), 67 deletions(-) diff --git a/static/app/components/repositories/useBulkUpdateRepositorySettings.tsx b/static/app/components/repositories/useBulkUpdateRepositorySettings.tsx index 9d6d358c609885..5073bc50032ed9 100644 --- a/static/app/components/repositories/useBulkUpdateRepositorySettings.tsx +++ b/static/app/components/repositories/useBulkUpdateRepositorySettings.tsx @@ -1,5 +1,6 @@ import {getRepositoryWithSettingsQueryKey} from 'sentry/components/repositories/useRepositoryWithSettings'; import type {RepositoryWithSettings} from 'sentry/types/integrations'; +import type {CodeReviewTrigger} from 'sentry/types/seer'; import { fetchMutation, useMutation, @@ -20,7 +21,7 @@ type RepositorySettings = enabledCodeReview?: never; } | { - codeReviewTriggers: string[]; + codeReviewTriggers: CodeReviewTrigger[]; enabledCodeReview: boolean; repositoryIds: string[]; }; diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx index 72e62336054e77..02fa139d2ab990 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx @@ -132,33 +132,34 @@ export function SeerRepoTable() { isFetchingNextPage, } = result; - const {mutate: mutateRepositorySettings} = useBulkUpdateRepositorySettings({ - onSuccess: mutations => { - const mutationMap = new Map(mutations.map(m => [m.id, m])); - queryClient.setQueryData(queryOptions.queryKey, prev => { - if (!prev) { - return prev; - } - return { - ...prev, - pages: prev.pages.map(page => ({ - ...page, - json: page.json.map(repo => mutationMap.get(repo.id) ?? repo), - })), - }; - }); - }, - onSettled: mutations => { - queryClient.invalidateQueries({ - queryKey: getSeerOnboardingCheckQueryOptions({organization}).queryKey, - }); - (mutations ?? []).forEach(mutation => { + const {mutate: mutateRepositorySettings, mutateAsync: mutateRepositorySettingsAsync} = + useBulkUpdateRepositorySettings({ + onSuccess: mutations => { + const mutationMap = new Map(mutations.map(m => [m.id, m])); + queryClient.setQueryData(queryOptions.queryKey, prev => { + if (!prev) { + return prev; + } + return { + ...prev, + pages: prev.pages.map(page => ({ + ...page, + json: page.json.map(repo => mutationMap.get(repo.id) ?? repo), + })), + }; + }); + }, + onSettled: mutations => { queryClient.invalidateQueries({ - queryKey: getRepositoryWithSettingsQueryKey(organization, mutation.id), + queryKey: getSeerOnboardingCheckQueryOptions({organization}).queryKey, }); - }); - }, - }); + (mutations ?? []).forEach(mutation => { + queryClient.invalidateQueries({ + queryKey: getRepositoryWithSettingsQueryKey(organization, mutation.id), + }); + }); + }, + }); const knownIds = useMemo( () => repositories?.map(repository => repository.id) ?? [], @@ -207,8 +208,9 @@ export function SeerRepoTable() { gridColumns={GRID_COLUMNS} isFetchingNextPage={isFetchingNextPage} isPending={isPending} - mutateRepositorySettings={mutateRepositorySettings} + mutateRepositorySettings={mutateRepositorySettingsAsync} onSortClick={setSort} + repositories={repositories ?? []} sort={sort} /> {isPending ? ( diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx index ff1c61a46700e3..f0ea2a5dcf2f94 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx @@ -1,16 +1,23 @@ -import {Fragment} from 'react'; +import {Fragment, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; import {Checkbox} from '@sentry/scraps/checkbox'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; import {Flex} from '@sentry/scraps/layout'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import { + addErrorMessage, + addLoadingMessage, + addSuccessMessage, +} from 'sentry/actionCreators/indicator'; 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 type {RepositoryWithSettings} from 'sentry/types/integrations'; +import type {CodeReviewTrigger} from 'sentry/types/seer'; import {parseQueryKey} from 'sentry/utils/api/apiQueryKey'; import type {Sort} from 'sentry/utils/discover/fields'; import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState'; @@ -21,8 +28,11 @@ interface Props { gridColumns: string; isFetchingNextPage: boolean; isPending: boolean; - mutateRepositorySettings: ReturnType['mutate']; + mutateRepositorySettings: ReturnType< + typeof useBulkUpdateRepositorySettings + >['mutateAsync']; onSortClick: (key: Sort) => void; + repositories: RepositoryWithSettings[]; sort: Sort; } @@ -53,6 +63,7 @@ export function SeerRepoTableHeader({ isPending, mutateRepositorySettings, onSortClick, + repositories, sort, }: Props) { const canWrite = useCanWriteSettings(); @@ -71,34 +82,160 @@ export function SeerRepoTableHeader({ : undefined; const queryString = queryOptions?.query?.query; - const handleBulkCodeReview = (enabledCodeReview: boolean) => { + const selectedRepos = useMemo(() => { + return repositories.filter(repo => selectedIds.includes(repo.id)); + }, [repositories, selectedIds]); + + const currentCodeReviewValue = useMemo(() => { + const someEnabled = selectedRepos.some(repo => repo?.settings?.enabledCodeReview); + const someDisabled = selectedRepos.some( + repo => repo?.settings?.enabledCodeReview === false + ); + if (someEnabled && someDisabled) { + return undefined; + } + if (someEnabled) { + return 'enabled_code_review:enabled'; + } + if (someDisabled) { + return 'enabled_code_review:disabled'; + } + return undefined; + }, [selectedRepos]); + + const currentTriggersValue = useMemo((): CodeReviewTrigger[] => { + const someOnReadyForReview = selectedRepos.every(repo => + repo?.settings?.codeReviewTriggers?.includes('on_ready_for_review') + ); + const someOnNewCommit = selectedRepos.every(repo => + repo?.settings?.codeReviewTriggers?.includes('on_new_commit') + ); + return [ + ...(someOnReadyForReview ? ['on_ready_for_review' as const] : []), + ...(someOnNewCommit ? ['on_new_commit' as const] : []), + ]; + }, [selectedRepos]); + + const [isBulkUpdating, setIsBulkUpdating] = useState(false); + + const handleBulkCodeReview = async (enabledCodeReview: boolean) => { const repositoryIds = selectedIds === 'all' ? knownIds : selectedIds; - mutateRepositorySettings( - { - enabledCodeReview, - repositoryIds, - }, - { - onError: () => { - addErrorMessage( - tn( - 'Failed to update code review for %s repository', - 'Failed to update code review for %s repositories', - repositoryIds.length - ) - ); - }, - onSuccess: () => { - addSuccessMessage( - tn( - 'Code review updated for %s repository', - 'Code review updated for %s repositories', - repositoryIds.length - ) - ); - }, - } + setIsBulkUpdating(true); + addLoadingMessage( + tn( + 'Updating code review for %s repository…', + 'Updating code review for %s repositories…', + repositoryIds.length + ) ); + try { + await mutateRepositorySettings({enabledCodeReview, repositoryIds}); + addSuccessMessage( + tn( + 'Code review updated for %s repository', + 'Code review updated for %s repositories', + repositoryIds.length + ) + ); + } catch { + addErrorMessage( + tn( + 'Failed to update code review for %s repository', + 'Failed to update code review for %s repositories', + repositoryIds.length + ) + ); + } finally { + setIsBulkUpdating(false); + } + }; + + const handleBulkTriggers = async ({ + added, + removed, + }: { + added: CodeReviewTrigger | undefined; + removed: CodeReviewTrigger | undefined; + }) => { + const promises: Array> = []; + + if (added) { + const repoIdsWithZeroTriggers: string[] = []; + const repoIdsWithOneTrigger: string[] = []; + for (const repo of selectedRepos) { + if (repo.settings?.codeReviewTriggers?.length === 0) { + repoIdsWithZeroTriggers.push(repo.id); + } else if (!repo.settings?.codeReviewTriggers.includes(added)) { + repoIdsWithOneTrigger.push(repo.id); + } + } + // Some items start with 0 triggers, they'll be saved with 1 new trigger + if (repoIdsWithZeroTriggers.length > 0) { + promises.push( + mutateRepositorySettings({ + codeReviewTriggers: [added], + repositoryIds: repoIdsWithZeroTriggers, + }) + ); + } + // Some items start with 1 trigger, they'll be saved with 1 new trigger for a total of 2 + if (repoIdsWithOneTrigger.length > 0) { + promises.push( + mutateRepositorySettings({ + codeReviewTriggers: ['on_new_commit', 'on_ready_for_review'], + repositoryIds: repoIdsWithOneTrigger, + }) + ); + } + } + if (removed) { + const repoIdsWithOneTrigger: string[] = []; + const repoIdsWithTwoTriggers: string[] = []; + for (const repo of selectedRepos) { + if (repo.settings?.codeReviewTriggers?.length === 2) { + repoIdsWithTwoTriggers.push(repo.id); + } else if (repo.settings?.codeReviewTriggers?.includes(removed)) { + repoIdsWithOneTrigger.push(repo.id); + } + } + // Some items start with 2 triggers, we'll remove one + const remainingTrigger = + removed === 'on_new_commit' ? 'on_ready_for_review' : 'on_new_commit'; + if (repoIdsWithTwoTriggers.length > 0) { + promises.push( + mutateRepositorySettings({ + codeReviewTriggers: [remainingTrigger], + repositoryIds: repoIdsWithTwoTriggers, + }) + ); + } + // Some items start with 1 trigger, we'll remove it + if (repoIdsWithOneTrigger.length > 0) { + promises.push( + mutateRepositorySettings({ + codeReviewTriggers: [], + repositoryIds: repoIdsWithOneTrigger, + }) + ); + } + } + + if (promises.length === 0) { + return; + } + + setIsBulkUpdating(true); + addLoadingMessage(t('Updating triggers…')); + + const results = await Promise.allSettled(promises); + const hasError = results.some(r => r.status === 'rejected'); + setIsBulkUpdating(false); + + if (hasError) { + addErrorMessage(t('Failed to update triggers')); + } else { + addSuccessMessage(t('Triggers updated')); + } }; return ( @@ -147,22 +284,62 @@ export function SeerRepoTableHeader({ /> - ( + + {t('Code Review')} + + )} + options={[ + { + value: 'enabled_code_review:enabled', + label: t('Enable'), + disabled: isBulkUpdating, + }, + { + value: 'enabled_code_review:disabled', + label: t('Disable'), + disabled: isBulkUpdating, + }, + ]} + value={currentCodeReviewValue} + onChange={option => { + if (option.value === 'enabled_code_review:enabled') { + handleBulkCodeReview(true); + } else { + handleBulkCodeReview(false); + } + }} + /> + + + disabled={!canWrite} + multiple size="xs" - items={[ + trigger={props => ( + {t('Triggers')} + )} + options={[ { - key: 'on', - label: t('On'), - onAction: () => handleBulkCodeReview(true), + value: 'on_ready_for_review', + label: t('On Ready for Review'), + disabled: isBulkUpdating, }, { - key: 'off', - label: t('Off'), - onAction: () => handleBulkCodeReview(false), + value: 'on_new_commit', + label: t('On New Commit'), + disabled: isBulkUpdating, }, ]} - triggerLabel={t('Code Review')} + value={currentTriggersValue} + onChange={option => { + const value = option.map(v => v.value); + const added = value.findLast(v => !currentTriggersValue.includes(v)); + const removed = currentTriggersValue.findLast(v => !value.includes(v)); + handleBulkTriggers({added, removed}); + }} /> From 1e009ded26e91f026ff7c9c67300ad484e1378b0 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 15 Apr 2026 15:07:42 -0700 Subject: [PATCH 2/4] fix selectedIds === all case --- .../components/repoTable/seerRepoTableHeader.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx index f0ea2a5dcf2f94..396166c6d96494 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx @@ -83,6 +83,9 @@ export function SeerRepoTableHeader({ const queryString = queryOptions?.query?.query; const selectedRepos = useMemo(() => { + if (selectedIds === 'all') { + return repositories; + } return repositories.filter(repo => selectedIds.includes(repo.id)); }, [repositories, selectedIds]); From 4354dfe4f4b543b50687a9b16f35ee18ed464805 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 15 Apr 2026 15:08:40 -0700 Subject: [PATCH 3/4] fix missing optional chaining operator --- .../seerAutomation/components/repoTable/seerRepoTableHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx index 396166c6d96494..943b88f9fb8622 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx @@ -168,7 +168,7 @@ export function SeerRepoTableHeader({ for (const repo of selectedRepos) { if (repo.settings?.codeReviewTriggers?.length === 0) { repoIdsWithZeroTriggers.push(repo.id); - } else if (!repo.settings?.codeReviewTriggers.includes(added)) { + } else if (!repo.settings?.codeReviewTriggers?.includes(added)) { repoIdsWithOneTrigger.push(repo.id); } } From 85e0a186cf9bbd1d1f3fcbf8f0e84e0900763300 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 15 Apr 2026 15:09:56 -0700 Subject: [PATCH 4/4] falsy length is a better check instead of === 0 --- .../seerAutomation/components/repoTable/seerRepoTableHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx index 943b88f9fb8622..24999284893cc2 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTableHeader.tsx @@ -166,7 +166,7 @@ export function SeerRepoTableHeader({ const repoIdsWithZeroTriggers: string[] = []; const repoIdsWithOneTrigger: string[] = []; for (const repo of selectedRepos) { - if (repo.settings?.codeReviewTriggers?.length === 0) { + if (!repo.settings?.codeReviewTriggers?.length) { repoIdsWithZeroTriggers.push(repo.id); } else if (!repo.settings?.codeReviewTriggers?.includes(added)) { repoIdsWithOneTrigger.push(repo.id);