diff --git a/static/app/components/core/form/index.ts b/static/app/components/core/form/index.ts index 320db7f9a744f4..9befc826038bb7 100644 --- a/static/app/components/core/form/index.ts +++ b/static/app/components/core/form/index.ts @@ -8,6 +8,7 @@ export { withForm, } from './scrapsForm'; export {AutoSaveForm} from './autoSaveForm'; +export {AutoSaveContextProvider} from './autoSaveContext'; export {FieldGroup} from './layout/fieldGroup'; export {FormSearch} from './FormSearch'; export {FORM_FIELD_REGISTRY} from './generatedFieldRegistry'; diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts index 0fad08866b7dcf..50d7b9835e780b 100644 --- a/static/app/components/events/autofix/types.ts +++ b/static/app/components/events/autofix/types.ts @@ -134,18 +134,12 @@ export interface AutofixRepoDefinition { provider: string; } -export interface BranchOverride { +interface BranchOverride { branch_name: string; tag_name: string; tag_value: string; } -export interface RepoSettings { - branch: string; - branch_overrides: BranchOverride[]; - instructions: string; -} - export interface SeerRepoDefinition { external_id: string; name: string; diff --git a/static/app/components/seer/legacy/addAutofixRepoModal.tsx b/static/app/components/seer/legacy/addAutofixRepoModal.tsx index 27b732d686a66f..39a05c434e16a8 100644 --- a/static/app/components/seer/legacy/addAutofixRepoModal.tsx +++ b/static/app/components/seer/legacy/addAutofixRepoModal.tsx @@ -1,4 +1,12 @@ -import {Fragment, useCallback, useMemo, useRef, useState, type ChangeEvent} from 'react'; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, +} from 'react'; import styled from '@emotion/styled'; import {useInfiniteQuery} from '@tanstack/react-query'; import {useVirtualizer} from '@tanstack/react-virtual'; @@ -12,7 +20,6 @@ import {Link} from '@sentry/scraps/link'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {MAX_REPOS_LIMIT} from 'sentry/components/seer/legacy/constants'; -import {SelectableRepoItem} from 'sentry/components/seer/legacy/selectableRepoItem'; import {IconSearch} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import {useFetchAllPages} from 'sentry/utils/api/apiFetch'; @@ -22,19 +29,28 @@ import { } from 'sentry/utils/repositories/repoQueryOptions'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {SelectableRepoItem} from './selectableRepoItem'; + type Props = ModalRenderProps & { /** - * Callback function triggered when the modal is saved. + * Repositories currently selected for Autofix in the parent component. */ - onSave: (repoIds: string[]) => void; + hiddenExternalIds: string[]; + /** - * Repositories currently selected for Autofix in the parent component. + * Callback function triggered when the modal is saved. */ - selectedRepoIds: string[]; + onSave: ({ + selectedExternalIds, + selectedRepoIds, + }: { + selectedExternalIds: string[]; + selectedRepoIds: string[]; + }) => void; }; export function AddAutofixRepoModal({ - selectedRepoIds, + hiddenExternalIds, onSave, Header, Body, @@ -49,53 +65,49 @@ export function AddAutofixRepoModal({ }); useFetchAllPages({result: repositoriesQuery}); const {data: repositories, isFetching: isFetchingRepositories} = repositoriesQuery; + const [modalSearchQuery, setModalSearchQuery] = useState(''); + const [selectedExternalIds, setSelectedExternalIds] = useState([]); const [showMaxLimitAlert, setShowMaxLimitAlert] = useState(false); - const [modalSelectedRepoIds, setModalSelectedRepoIds] = useState(selectedRepoIds); - - const newModalSelectedRepoIds = modalSelectedRepoIds.filter( - id => !selectedRepoIds.includes(id) - ); - const unselectedRepositories = useMemo(() => { + const filteredRepositories = useMemo(() => { if (!repositories) { return []; } - return repositories.filter(repo => !selectedRepoIds.includes(repo.externalId)); - }, [repositories, selectedRepoIds]); - - const filteredModalRepositories = useMemo(() => { - let filtered = unselectedRepositories; - if (modalSearchQuery.trim()) { - const query = modalSearchQuery.toLowerCase(); - filtered = unselectedRepositories.filter(repo => - repo.name.toLowerCase().includes(query) - ); - } - - return filtered.filter(repo => repo.provider?.id && repo.provider.id !== 'unknown'); - }, [unselectedRepositories, modalSearchQuery]); - - const handleToggleRepository = useCallback((repoId: string) => { - setModalSelectedRepoIds(prev => { - if (prev.includes(repoId)) { - setShowMaxLimitAlert(false); - return prev.filter(id => id !== repoId); + const query = modalSearchQuery.trim().toLowerCase(); + return repositories.filter(repo => { + if (hiddenExternalIds.includes(repo.externalId)) { + return false; } - if (prev.length >= MAX_REPOS_LIMIT) { - setShowMaxLimitAlert(true); - return prev; + if (query && !repo.name.toLowerCase().includes(query)) { + return false; } - setShowMaxLimitAlert(false); - return [...prev, repoId]; + return true; }); - }, []); + }, [repositories, hiddenExternalIds, modalSearchQuery]); + + const handleToggleRepository = useCallback( + (externalId: string) => { + setSelectedExternalIds(prev => { + if (prev.includes(externalId)) { + return prev.filter(id => id !== externalId); + } + return hiddenExternalIds.length + prev.length >= MAX_REPOS_LIMIT + ? prev + : [...prev, externalId]; + }); + }, + [hiddenExternalIds.length] + ); + + useEffect(() => { + setShowMaxLimitAlert(selectedExternalIds.length >= MAX_REPOS_LIMIT); + }, [selectedExternalIds.length]); - // Virtualizer setup (simplified based on docs) const parentRef = useRef(null); const rowVirtualizer = useVirtualizer({ - count: filteredModalRepositories.length, + count: filteredRepositories.length, getScrollElement: () => parentRef.current, estimateSize: () => 36, overscan: 20, @@ -135,9 +147,9 @@ export function AddAutofixRepoModal({ {t('Loading repositories...')} - ) : filteredModalRepositories.length === 0 ? ( + ) : filteredRepositories.length === 0 ? ( - {modalSearchQuery.trim() && unselectedRepositories.length > 0 + {modalSearchQuery.trim() ? t('No matching repositories found.') : t('All available repositories have been added.')} @@ -151,7 +163,7 @@ export function AddAutofixRepoModal({ }} > {rowVirtualizer.getVirtualItems().map(virtualItem => { - const repo = filteredModalRepositories[virtualItem.index]!; + const repo = filteredRepositories[virtualItem.index]!; return (
@@ -191,16 +203,19 @@ export function AddAutofixRepoModal({ diff --git a/static/app/components/seer/legacy/autofixRepoItem.tsx b/static/app/components/seer/legacy/autofixRepoItem.tsx deleted file mode 100644 index 423e14297b3cca..00000000000000 --- a/static/app/components/seer/legacy/autofixRepoItem.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import {useEffect, useState, type ChangeEvent} from 'react'; -import styled from '@emotion/styled'; - -import {Button} from '@sentry/scraps/button'; -import {InputGroup} from '@sentry/scraps/input'; -import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; -import {Flex, Grid, Stack} from '@sentry/scraps/layout'; -import {TextArea} from '@sentry/scraps/textarea'; - -import {Confirm} from 'sentry/components/confirm'; -import type {BranchOverride, RepoSettings} from 'sentry/components/events/autofix/types'; -import {QuestionTooltip} from 'sentry/components/questionTooltip'; -import { - IconAdd, - IconClose, - IconCommit, - IconDelete, - IconChevron as IconExpandToggle, - IconTag, -} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import type {Repository} from 'sentry/types/integrations'; - -interface Props { - onRemove: () => void; - onSettingsChange: (settings: RepoSettings) => void; - repo: Repository; - settings: RepoSettings; -} - -export function AutofixRepoItem({repo, onRemove, settings, onSettingsChange}: Props) { - const [isExpanded, setIsExpanded] = useState(false); - const [isEditingBranch, setIsEditingBranch] = useState(false); - const [branchInputValue, setBranchInputValue] = useState(settings.branch); - const [instructionsValue, setInstructionsValue] = useState(settings.instructions); - const [branchOverridesValue, setBranchOverridesValue] = useState( - settings.branch_overrides || [] - ); - const [originalValues, setOriginalValues] = useState({ - branch: settings.branch, - instructions: settings.instructions, - branch_overrides: settings.branch_overrides || [], - }); - const [isDirty, setIsDirty] = useState(false); - - const toggleExpanded = () => { - setIsExpanded(!isExpanded); - }; - - useEffect(() => { - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setBranchInputValue(settings.branch); - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setInstructionsValue(settings.instructions); - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setBranchOverridesValue(settings.branch_overrides || []); - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setOriginalValues({ - branch: settings.branch, - instructions: settings.instructions, - branch_overrides: settings.branch_overrides || [], - }); - setIsDirty(false); - }, [settings.branch, settings.instructions, settings.branch_overrides]); - - useEffect(() => { - const newIsDirty = - branchInputValue !== originalValues.branch || - instructionsValue !== originalValues.instructions || - JSON.stringify(branchOverridesValue) !== - JSON.stringify(originalValues.branch_overrides); - setIsDirty(newIsDirty); - }, [branchInputValue, instructionsValue, branchOverridesValue, originalValues]); - - const handleBranchInputChange = (e: ChangeEvent) => { - e.stopPropagation(); - setBranchInputValue(e.target.value); - }; - - const handleInstructionsChange = (e: ChangeEvent) => { - e.stopPropagation(); - setInstructionsValue(e.target.value); - }; - - const saveChanges = () => { - // Filter out incomplete branch overrides - all fields must be filled - const completeOverrides = branchOverridesValue.filter( - override => - override.tag_name.trim() !== '' && - override.tag_value.trim() !== '' && - override.branch_name.trim() !== '' - ); - - onSettingsChange({ - branch: branchInputValue, - instructions: instructionsValue, - branch_overrides: completeOverrides, - }); - setIsEditingBranch(false); - }; - - const cancelChanges = () => { - setBranchInputValue(originalValues.branch); - setInstructionsValue(originalValues.instructions); - setBranchOverridesValue(originalValues.branch_overrides); - setIsEditingBranch(false); - setIsDirty(false); - }; - - const addBranchOverride = () => { - setBranchOverridesValue([ - ...branchOverridesValue, - {tag_name: '', tag_value: '', branch_name: ''}, - ]); - }; - - const updateBranchOverride = (index: number, override: BranchOverride) => { - const newOverrides = [...branchOverridesValue]; - newOverrides[index] = override; - setBranchOverridesValue(newOverrides); - }; - - const removeBranchOverride = (index: number) => { - setBranchOverridesValue(branchOverridesValue.filter((_, i) => i !== index)); - }; - - return ( - - - - - - - {repo.name} - - - {repo.provider?.name || t('Unknown Provider')} - - {isExpanded && ( - - -
- - - {t('Working Branch for Seer')} - - - - - {t('By default, look at')} - - - - - - !isEditingBranch && setIsEditingBranch(true)} - placeholder={t('Default branch')} - autoFocus={isEditingBranch && !settings.branch} - /> - {(isEditingBranch ? branchInputValue : settings.branch) && ( - - } - onClick={() => { - setBranchInputValue(''); - if (!isEditingBranch) { - setIsEditingBranch(true); - } - setIsDirty(true); - }} - aria-label={t('Clear branch and use default')} - tooltipProps={{title: t('Clear branch and use default')}} - /> - - )} - - } - onClick={addBranchOverride} - variant="transparent" - > - {t('Add an override for a tag')} - - - - - - {branchOverridesValue.map((override, index) => ( - - - {t('When')} - - - - - ) => - updateBranchOverride(index, { - ...override, - tag_name: e.target.value, - }) - } - placeholder={t('Tag name (e.g. environment)')} - /> - - {t('is')} - - ) => - updateBranchOverride(index, { - ...override, - tag_value: e.target.value, - }) - } - placeholder={t('Tag value (e.g. staging)')} - /> - - {t('look at')} - - - - - ) => - updateBranchOverride(index, { - ...override, - branch_name: e.target.value, - }) - } - placeholder={t('Branch name (e.g. dev)')} - /> - - -
- - {repo.name}, - })} - > - - - {isDirty && ( - - - - - )} - -
-
- )} -
- ); -} - -const SelectedRepoHeader = styled('div')` - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - padding: ${p => p.theme.space.lg} ${p => p.theme.space['2xl']}; - cursor: pointer; -`; - -const RepoName = styled('div')` - font-weight: 600; -`; - -const RepoProvider = styled('div')` - font-size: ${p => p.theme.font.size.sm}; - color: ${p => p.theme.tokens.content.secondary}; - margin-top: ${p => p.theme.space['2xs']}; -`; - -const ExpandedContent = styled('div')` - padding: 0 ${p => p.theme.space.xl} ${p => p.theme.space.md} 40px; - background-color: ${p => p.theme.tokens.background.primary}; - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.xl}; - border-top: 1px solid ${p => p.theme.tokens.border.primary}; -`; - -const SettingsGroup = styled('div')` - border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; - padding-bottom: ${p => p.theme.space.lg}; - padding-top: ${p => p.theme.space.lg}; - - &:last-child { - margin-bottom: 0; - } -`; - -const BranchInputLabel = styled('label')` - display: flex; - align-items: center; - font-size: ${p => p.theme.font.size.lg}; - color: ${p => p.theme.tokens.content.primary}; - margin-bottom: ${p => p.theme.space.sm}; - gap: ${p => p.theme.space.md}; -`; - -const SubHeader = styled('div')` - font-size: ${p => p.theme.font.size.md}; - color: ${p => p.theme.tokens.content.secondary}; - font-weight: ${p => p.theme.font.weight.sans.medium}; -`; - -const StyledTextArea = styled(TextArea)` - width: 100%; - resize: vertical; - min-height: 80px; -`; - -const ClearButton = styled(Button)` - color: ${p => p.theme.colors.gray400}; - - &:hover { - color: ${p => p.theme.colors.gray800}; - } -`; - -const StyledIconExpandToggle = styled(IconExpandToggle)` - margin-right: ${p => p.theme.space.xs}; -`; - -const AddOverrideButton = styled(Button)` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const BranchOverrideItem = styled('div')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; - padding-top: ${p => p.theme.space.md}; - padding-bottom: ${p => p.theme.space.md}; -`; - -const OverrideInputGroup = styled(InputGroup)` - flex: 1; - min-width: 0; -`; diff --git a/static/app/components/seer/legacy/autofixRepositories.stories.tsx b/static/app/components/seer/legacy/autofixRepositories.stories.tsx deleted file mode 100644 index 4bb711293cc2a8..00000000000000 --- a/static/app/components/seer/legacy/autofixRepositories.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import {parseAsString, useQueryState} from 'nuqs'; - -import {Flex} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; - -import {AutofixRepositories} from 'sentry/components/seer/legacy/autofixRepositories'; -import * as Storybook from 'sentry/stories'; -import {useProjects} from 'sentry/utils/useProjects'; - -export default Storybook.story('AutofixRepositories (Legacy)', story => { - story('Default', () => { - const [projectSlug, setProjectSlug] = useQueryState('project', parseAsString); - const {projects} = useProjects(); - const project = projects.find(p => p.slug === projectSlug); - - return ( - - - {project ? ( - - ) : ( - - Select a project to view the story - - )} - - ); - }); -}); diff --git a/static/app/components/seer/legacy/autofixRepositories.tsx b/static/app/components/seer/legacy/autofixRepositories.tsx deleted file mode 100644 index 6dfd06f3a31ae2..00000000000000 --- a/static/app/components/seer/legacy/autofixRepositories.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import {useInfiniteQuery} from '@tanstack/react-query'; - -import {Alert} from '@sentry/scraps/alert'; -import {Button} from '@sentry/scraps/button'; -import {Flex, Stack} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; -import {useModal} from '@sentry/scraps/modal'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {useProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; -import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; -import type { - ProjectSeerPreferences, - RepoSettings, -} from 'sentry/components/events/autofix/types'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {QuestionTooltip} from 'sentry/components/questionTooltip'; -import {AddAutofixRepoModal} from 'sentry/components/seer/legacy/addAutofixRepoModal'; -import {AutofixRepoItem} from 'sentry/components/seer/legacy/autofixRepoItem'; -import {MAX_REPOS_LIMIT} from 'sentry/components/seer/legacy/constants'; -import {IconAdd} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; -import type {Project} from 'sentry/types/project'; -import {useFetchAllPages} from 'sentry/utils/api/apiFetch'; -import { - organizationRepositoriesInfiniteOptions, - selectUniqueRepos, -} from 'sentry/utils/repositories/repoQueryOptions'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -interface ProjectSeerProps { - project: Project; -} - -export function AutofixRepositories({project}: ProjectSeerProps) { - const {openModal} = useModal(); - - const theme = useTheme(); - const organization = useOrganization(); - const repositoriesQuery = useInfiniteQuery({ - ...organizationRepositoriesInfiniteOptions({organization, query: {per_page: 100}}), - select: selectUniqueRepos, - }); - useFetchAllPages({result: repositoriesQuery}); - const {data: repositories, isFetching: isFetchingRepositories} = repositoriesQuery; - const {data, isPending: isLoadingPreferences} = useProjectSeerPreferences(project); - const {preference, code_mapping_repos: codeMappingRepos} = data ?? {}; - const {mutate: updateProjectSeerPreferences} = useUpdateProjectSeerPreferences(project); - - const [selectedRepoIds, setSelectedRepoIds] = useState([]); - const [repoSettings, setRepoSettings] = useState>({}); - const [showSaveNotice, setShowSaveNotice] = useState(false); - - const getDefaultStoppingPoint = - useCallback((): ProjectSeerPreferences['automated_run_stopping_point'] => { - if (organization.features.includes('seat-based-seer-enabled')) { - return organization.autoOpenPrs ? 'open_pr' : 'code_changes'; - } - return 'root_cause'; - }, [organization.features, organization.autoOpenPrs]); - - const [automatedRunStoppingPoint, setAutomatedRunStoppingPoint] = useState< - ProjectSeerPreferences['automated_run_stopping_point'] - >(getDefaultStoppingPoint()); - - useEffect(() => { - if (repositories) { - if (preference?.repositories) { - // Handle existing preferences - const preferencesMap = new Map( - preference.repositories.map(repo => [ - repo.external_id, - { - branch: repo.branch_name || '', - instructions: repo.instructions || '', - branch_overrides: repo.branch_overrides || [], - }, - ]) - ); - - setSelectedRepoIds(preference.repositories.map(repo => repo.external_id)); - - const initialSettings: Record = {}; - repositories.forEach(repo => { - initialSettings[repo.externalId] = preferencesMap.get(repo.externalId) || { - branch: '', - instructions: '', - branch_overrides: [], - }; - }); - - setRepoSettings(initialSettings); - setAutomatedRunStoppingPoint( - preference.automated_run_stopping_point || getDefaultStoppingPoint() - ); - } else if (codeMappingRepos?.length) { - // Set default settings using codeMappingRepos when no preferences exist - const repoIds = codeMappingRepos.map(repo => repo.external_id); - setSelectedRepoIds(repoIds); - - const initialSettings: Record = {}; - repositories.forEach(repo => { - initialSettings[repo.externalId] = { - branch: '', - instructions: '', - branch_overrides: [], - }; - }); - - setRepoSettings(initialSettings); - setAutomatedRunStoppingPoint(getDefaultStoppingPoint()); - } - } - }, [ - preference, - repositories, - codeMappingRepos, - updateProjectSeerPreferences, - getDefaultStoppingPoint, - ]); - - const updatePreferences = useCallback( - ( - updatedIds?: string[], - updatedSettings?: Record, - newStoppingPoint?: 'root_cause' | 'solution' | 'code_changes' | 'open_pr' - ) => { - if (!repositories) { - return; - } - const idsToUse = updatedIds || selectedRepoIds; - const settingsToUse = updatedSettings || repoSettings; - const stoppingPointToUse = newStoppingPoint || automatedRunStoppingPoint; - const selectedRepos = repositories.filter(repo => - idsToUse.includes(repo.externalId) - ); - const reposData = selectedRepos.map(repo => { - const [owner, name] = (repo.name || '/').split('/'); - let provider = repo.provider?.id || ''; - if (provider?.startsWith('integrations:')) { - provider = provider.split(':')[1]!; - } - - return { - organization_id: parseInt(organization.id, 10), - integration_id: repo.integrationId, - provider, - owner: owner || '', - name: name || repo.name || '', - external_id: repo.externalId, - branch_name: settingsToUse[repo.externalId]?.branch || '', - instructions: settingsToUse[repo.externalId]?.instructions || '', - branch_overrides: settingsToUse[repo.externalId]?.branch_overrides || [], - }; - }); - updateProjectSeerPreferences({ - repositories: reposData, - automated_run_stopping_point: stoppingPointToUse, - }); - setShowSaveNotice(true); - }, - [ - organization.id, - repositories, - selectedRepoIds, - repoSettings, - automatedRunStoppingPoint, - updateProjectSeerPreferences, - ] - ); - - const handleSaveModalSelections = useCallback( - (modalSelectedIds: string[]) => { - setSelectedRepoIds(modalSelectedIds); - updatePreferences(modalSelectedIds); - }, - [updatePreferences] - ); - - const removeRepository = (repoId: string) => { - setSelectedRepoIds(prevSelectedIds => { - const newIds = prevSelectedIds.filter(id => id !== repoId); - updatePreferences(newIds); - return newIds; - }); - }; - - const updateRepoSettings = (repoId: string, settings: RepoSettings) => { - setRepoSettings(prev => { - const newSettings = { - ...prev, - [repoId]: settings, - }; - - updatePreferences(undefined, newSettings); - - return newSettings; - }); - }; - - const {unselectedRepositories, filteredSelectedRepositories} = useMemo(() => { - if (!repositories || repositories.length === 0) { - return { - unselectedRepositories: [], - filteredSelectedRepositories: [], - }; - } - - const selected = repositories.filter(repo => - selectedRepoIds.includes(repo.externalId) - ); - const unselected = repositories.filter( - repo => !selectedRepoIds.includes(repo.externalId) - ); - - const filteredSelected = selected.filter( - repo => repo.provider?.id && repo.provider.id !== 'unknown' && repo.integrationId - ); - - return { - unselectedRepositories: unselected, - filteredSelectedRepositories: filteredSelected, - }; - }, [repositories, selectedRepoIds]); - - const isRepoLimitReached = selectedRepoIds.length >= MAX_REPOS_LIMIT; - - const openAddRepoModal = () => { - openModal(deps => ( - - )); - }; - - return ( - - - - {t('Working Repositories')} - , - } - )} - size="sm" - isHoverable - /> - -
- - -
{t('GitHub')}
- - ), - to: `/settings/${organization.slug}/integrations/github/`, - }, - { - key: 'github_enterprise', - textValue: t('GitHub Enterprise'), - label: ( - - -
{t('GitHub Enterprise')}
-
- ), - to: `/settings/${organization.slug}/integrations/github_enterprise/`, - }, - ]} - /> - - ), - } - ) - : null - } - > - - -
-
- - {showSaveNotice && ( - - {t( - 'Changes will apply on future Seer runs. Hit "Start Over" in the Seer panel to start a new run and use your new selected repositories.' - )} - - )} - {isFetchingRepositories || isLoadingPreferences ? ( - - - {t('Loading repositories...')} - - ) : filteredSelectedRepositories.length === 0 ? ( - - {t("Seer can't see your code. Click 'Add Repos' to give Seer access.")} - - ) : ( - - {filteredSelectedRepositories.map(repo => ( - { - updateRepoSettings(repo.externalId, settings); - }} - onRemove={() => { - removeRepository(repo.externalId); - }} - /> - ))} - - )} -
- ); -} - -const ReposContainer = styled('div')` - display: flex; - flex-direction: column; - - & > div:not(:last-child) { - border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; - } -`; - -const EmptyMessage = styled('div')` - padding: ${p => p.theme.space.xl}; - color: ${p => p.theme.tokens.content.danger}; - text-align: center; - font-size: ${p => p.theme.font.size.md}; -`; - -const StyledLoadingIndicator = styled(LoadingIndicator)` - margin: 0; -`; - -const LoadingMessage = styled('div')` - color: ${p => p.theme.tokens.content.secondary}; - font-size: ${p => p.theme.font.size.md}; -`; diff --git a/static/app/components/seer/projectAddRepoModal/projectAddRepoModal.tsx b/static/app/components/seer/projectAddRepoModal/projectAddRepoModal.tsx index 15e325782bd292..dcf6a8a1e3aeca 100644 --- a/static/app/components/seer/projectAddRepoModal/projectAddRepoModal.tsx +++ b/static/app/components/seer/projectAddRepoModal/projectAddRepoModal.tsx @@ -167,7 +167,7 @@ export function ProjectAddRepoModal({ {field => ( - + { diff --git a/static/app/components/seer/projectDetails/autofixRepositoriesItem.tsx b/static/app/components/seer/projectDetails/autofixRepositoriesItem.tsx index fa96f5a586b6b6..4127f3b3dab128 100644 --- a/static/app/components/seer/projectDetails/autofixRepositoriesItem.tsx +++ b/static/app/components/seer/projectDetails/autofixRepositoriesItem.tsx @@ -1,93 +1,107 @@ -import {Fragment, useState} from 'react'; +import {Fragment, useRef, useState} from 'react'; import styled from '@emotion/styled'; +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {z} from 'zod'; import {Button} from '@sentry/scraps/button'; -import {Input} from '@sentry/scraps/input'; +import { + AutoSaveContextProvider, + AutoSaveForm, + defaultFormOptions, + useScrapsForm, +} from '@sentry/scraps/form'; +import {InfoTip} from '@sentry/scraps/info'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; import {Confirm} from 'sentry/components/confirm'; -import type { - BranchOverride, - SeerRepoDefinition, -} from 'sentry/components/events/autofix/types'; -import {QuestionTooltip} from 'sentry/components/questionTooltip'; -import {AutofixRepositoriesItemBranchOverride} from 'sentry/components/seer/projectDetails/autofixRepositoriesItemBranchOverride'; import {overrideHasAllValues} from 'sentry/components/seer/projectDetails/overrideHasAllValues'; +import {overrideHasAnyValue} from 'sentry/components/seer/projectDetails/overrideHasAnyValue'; import {IconAdd} from 'sentry/icons/iconAdd'; import {IconChevron} from 'sentry/icons/iconChevron'; import {IconDelete} from 'sentry/icons/iconDelete'; import {t, tct, tn} from 'sentry/locale'; +import type {AvatarProject} from 'sentry/types/project'; +import {getMutateSeerProjectRepoOptions} from 'sentry/utils/seer/seerProjectRepos'; +import type {SeerProjectReposResponse} from 'sentry/utils/seer/types'; +import {useOrganization} from 'sentry/utils/useOrganization'; interface Props { canWrite: boolean; - onRemoveRepo: () => void; - onUpdateRepo: (updatedRepo: SeerRepoDefinition) => void; - repositories: SeerRepoDefinition[]; - repository: SeerRepoDefinition; + includeInstructions: boolean; + onRemoveRepo: ({repoId}: {repoId: string}) => void; + project: AvatarProject; + repositories: SeerProjectReposResponse[]; + repository: SeerProjectReposResponse; } -const DEFAULT_OVERRIDE: BranchOverride = {tag_name: '', tag_value: '', branch_name: ''}; - -function areOverridesEqual(a: BranchOverride[], b: BranchOverride[]) { - if (a.length !== b.length) { - return false; - } - return a.every((override, idx) => { - const other = b[idx]; - return ( - override.branch_name === other?.branch_name && - override.tag_name === other.tag_name && - override.tag_value === other.tag_value - ); +const overrideItemSchema = z + .object({ + id: z.string(), + branchName: z.string(), + tagName: z.string(), + tagValue: z.string(), + }) + .superRefine((override, ctx) => { + if (!overrideHasAnyValue(override)) { + return; + } + if (!override.tagName.trim()) { + ctx.addIssue({code: 'custom', path: ['tagName'], message: 'Required'}); + } + if (!override.tagValue.trim()) { + ctx.addIssue({code: 'custom', path: ['tagValue'], message: 'Required'}); + } + if (!override.branchName.trim()) { + ctx.addIssue({code: 'custom', path: ['branchName'], message: 'Required'}); + } }); -} + +const repoSchema = z.object({ + branchName: z.string().optional(), + branchOverrides: z + .array(overrideItemSchema) + .transform(overrides => overrides.filter(overrideHasAllValues)), + instructions: z.string().optional(), +}); export function AutofixRepositoriesItem({ canWrite, - repository, - repositories, + includeInstructions, onRemoveRepo, - onUpdateRepo, + project, + repositories, + repository, }: Props) { + const queryClient = useQueryClient(); + const organization = useOrganization(); const [isExpanded, setIsExpanded] = useState(false); - // We keep state with local overrides so the user can edit things without - // sending incomplete changes to the server. All fields are required before - // an override can be saved. - const [localOverrides, setLocalOverrides] = useState(repository.branch_overrides || []); - - const handleUpdateOverride = (idx: number, updatedOverride: BranchOverride) => { - const newLocalOverrides = localOverrides.toSpliced(idx, 1, updatedOverride); - setLocalOverrides(newLocalOverrides); - - // Only sync valid overrides to the server if they changed - const branchOverrides = newLocalOverrides.filter(overrideHasAllValues); - if (!areOverridesEqual(branchOverrides, repository.branch_overrides || [])) { - onUpdateRepo({ - ...repository, - branch_overrides: branchOverrides, - }); - } - }; - - const handleRemoveOverride = (idx: number) => { - const newLocalOverrides = localOverrides.toSpliced(idx, 1); - setLocalOverrides(newLocalOverrides); - - // Sync valid overrides to the server if they changed - const branchOverrides = newLocalOverrides.filter(overrideHasAllValues); - if (!areOverridesEqual(branchOverrides, repository.branch_overrides || [])) { - onUpdateRepo({ - ...repository, - branch_overrides: branchOverrides, - }); - } - }; + const mutationOptions = getMutateSeerProjectRepoOptions({ + organization, + project, + queryClient, + repoId: repository.repositoryId, + }); + + const {mutateAsync: handleUpdateRepo, status: mutationStatus} = + useMutation(mutationOptions); + const resetOnErrorRef = useRef(false); - const handleAddOverride = () => { - setLocalOverrides([...localOverrides, {...DEFAULT_OVERRIDE}]); - }; + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: { + branchOverrides: repository.branchOverrides, + }, + validators: { + onDynamic: repoSchema, + }, + listeners: { + onChangeDebounceMs: 1000, + onChange: ({formApi}) => formApi.handleSubmit(), + }, + onSubmit: ({value}) => handleUpdateRepo(repoSchema.parse(value)), + }); return ( @@ -124,7 +138,7 @@ export function AutofixRepositoriesItem({ onRemoveRepo({repoId: repository.repositoryId})} header={ {tct('Are you sure you want to remove [repo] from Autofix?', { @@ -165,7 +179,7 @@ export function AutofixRepositoriesItem({ {t('(Optional) Select Working Branch for Seer')} - - - {t('By default, look at')} - onUpdateRepo({...repository, branch_name: e.target.value})} - placeholder={t('Default branch')} - size="sm" - style={{width: '200px'}} - value={repository.branch_name} - /> - - {localOverrides.map((override, idx) => ( - handleUpdateOverride(idx, updated)} - onRemoveOverride={() => handleRemoveOverride(idx)} - override={override} - /> - ))} - - + + + )} + + + + {includeInstructions && ( + - {t('Add Override')} - - + {field => ( + + {t('Context for Seer')} + + + )} + + )} )} diff --git a/static/app/components/seer/projectDetails/autofixRepositoriesItemBranchOverride.tsx b/static/app/components/seer/projectDetails/autofixRepositoriesItemBranchOverride.tsx deleted file mode 100644 index 6074c5e9a22bec..00000000000000 --- a/static/app/components/seer/projectDetails/autofixRepositoriesItemBranchOverride.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import {useTheme} from '@emotion/react'; - -import {Button} from '@sentry/scraps/button'; -import {Input} from '@sentry/scraps/input'; -import {Flex} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; - -import type {BranchOverride} from 'sentry/components/events/autofix/types'; -import {overrideHasAllValues} from 'sentry/components/seer/projectDetails/overrideHasAllValues'; -import {overrideHasAnyValue} from 'sentry/components/seer/projectDetails/overrideHasAnyValue'; -import {IconCheckmark} from 'sentry/icons/iconCheckmark'; -import {IconClose} from 'sentry/icons/iconClose'; -import {IconDelete} from 'sentry/icons/iconDelete'; -import {t} from 'sentry/locale'; - -interface Props { - canWrite: boolean; - onRemoveOverride: () => void; - onUpdateOverride: (updatedOverride: BranchOverride) => void; - override: BranchOverride; -} - -export function AutofixRepositoriesItemBranchOverride({ - canWrite, - onRemoveOverride, - onUpdateOverride, - override, -}: Props) { - const theme = useTheme(); - const hasAnyValue = Boolean(overrideHasAnyValue(override)); - const isValid = overrideHasAllValues(override); - - const getErrorStyle = (value: string) => - hasAnyValue && !value.trim() - ? {borderColor: theme.tokens.border.danger.vibrant} - : undefined; - - return ( - - - - - {t('When')} - onUpdateOverride({...override, tag_name: e.target.value})} - placeholder={t('Tag name (e.g. environment)')} - size="sm" - style={{ - ...getErrorStyle(override.tag_name), - width: '170px', - }} - value={override.tag_name} - /> - {t('is')} - onUpdateOverride({...override, tag_value: e.target.value})} - placeholder={t('Tag value (e.g. staging)')} - size="sm" - style={{ - ...getErrorStyle(override.tag_value), - width: '170px', - }} - value={override.tag_value} - /> - {t('look at')} - onUpdateOverride({...override, branch_name: e.target.value})} - placeholder={t('Branch name (e.g. dev)')} - size="sm" - style={{ - ...getErrorStyle(override.branch_name), - width: '170px', - }} - value={override.branch_name} - /> - + - {repoMap - .values() - .toArray() - .map(repository => ( - { - handleSaveRepoList( - repoMap - .values() - .toArray() - .filter(repo => repo.external_id !== repository.external_id) - ); - }} - onUpdateRepo={(updatedRepo: SeerRepoDefinition) => { - handleSaveRepoList( - repoMap - .values() - .toArray() - .map(repo => - repo.external_id === updatedRepo.external_id ? updatedRepo : repo - ) - ); - }} - /> - ))} + {data.map(repository => ( + + ))} ); diff --git a/static/app/components/seer/projectDetails/index.tsx b/static/app/components/seer/projectDetails/index.tsx index 9422f6922b72aa..83167ad108251d 100644 --- a/static/app/components/seer/projectDetails/index.tsx +++ b/static/app/components/seer/projectDetails/index.tsx @@ -5,12 +5,8 @@ import {ExternalLink} from '@sentry/scraps/link'; import {hasEveryAccess} from 'sentry/components/acl/access'; import Feature from 'sentry/components/acl/feature'; import {AnalyticsArea} from 'sentry/components/analyticsArea'; -import {useProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; -import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {AutofixAgent} from 'sentry/components/seer/projectDetails/autofixAgent'; -import {AutofixRepositories} from 'sentry/components/seer/projectDetails/autofixRepositoriesList'; +import {AutofixRepositoriesList} from 'sentry/components/seer/projectDetails/autofixRepositoriesList'; import {NightShift} from 'sentry/components/seer/projectDetails/nightShift'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; @@ -18,16 +14,8 @@ import type {DetailedProject} from 'sentry/types/project'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; -const DEFAULT_PREFERENCE: ProjectSeerPreferences = { - repositories: [], - automated_run_stopping_point: 'root_cause', - automation_handoff: undefined, -}; - export function SeerProjectDetails({project}: {project: DetailedProject}) { const organization = useOrganization(); - const {data, isPending, isError} = useProjectSeerPreferences(project); - const {preference, code_mapping_repos: codeMappingRepos} = data ?? {}; const canWrite = hasEveryAccess(['project:write'], {organization, project}); @@ -54,24 +42,18 @@ export function SeerProjectDetails({project}: {project: DetailedProject}) { )} - {isPending ? ( - - ) : isError ? ( - - ) : ( - - - - - - - - )} + + + + + + + + ); } diff --git a/static/app/components/seer/projectDetails/overrideHasAllValues.ts b/static/app/components/seer/projectDetails/overrideHasAllValues.ts index 167895cb63e5f1..f7180d4f98843a 100644 --- a/static/app/components/seer/projectDetails/overrideHasAllValues.ts +++ b/static/app/components/seer/projectDetails/overrideHasAllValues.ts @@ -1,9 +1,11 @@ -import type {BranchOverride} from 'sentry/components/events/autofix/types'; +import type {SeerProjectReposResponse} from 'sentry/utils/seer/types'; -export function overrideHasAllValues(override: BranchOverride) { +export function overrideHasAllValues( + override: SeerProjectReposResponse['branchOverrides'][number] +) { return ( - override.branch_name.trim() !== '' && - override.tag_name.trim() !== '' && - override.tag_value.trim() !== '' + override.branchName.trim() !== '' && + override.tagName.trim() !== '' && + override.tagValue.trim() !== '' ); } diff --git a/static/app/components/seer/projectDetails/overrideHasAnyValue.ts b/static/app/components/seer/projectDetails/overrideHasAnyValue.ts index 9894a64397cec7..484dc5a569ba1e 100644 --- a/static/app/components/seer/projectDetails/overrideHasAnyValue.ts +++ b/static/app/components/seer/projectDetails/overrideHasAnyValue.ts @@ -1,7 +1,9 @@ -import type {BranchOverride} from 'sentry/components/events/autofix/types'; +import type {SeerProjectReposResponse} from 'sentry/utils/seer/types'; -export function overrideHasAnyValue(override: BranchOverride) { +export function overrideHasAnyValue( + override: SeerProjectReposResponse['branchOverrides'][number] +) { return ( - override.tag_name.trim() || override.tag_value.trim() || override.branch_name.trim() + override.tagName.trim() || override.tagValue.trim() || override.branchName.trim() ); } diff --git a/static/app/utils/seer/seerProjectRepos.ts b/static/app/utils/seer/seerProjectRepos.ts new file mode 100644 index 00000000000000..3e513dfaeb525c --- /dev/null +++ b/static/app/utils/seer/seerProjectRepos.ts @@ -0,0 +1,350 @@ +import { + type InfiniteData, + mutationOptions, + type QueryClient, +} from '@tanstack/react-query'; + +import type {Repository} from 'sentry/types/integrations'; +import type {Organization} from 'sentry/types/organization'; +import type {AvatarProject} from 'sentry/types/project'; +import type {ApiResponse} from 'sentry/utils/api/apiFetch'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {organizationRepositoriesInfiniteOptions} from 'sentry/utils/repositories/repoQueryOptions'; +import type { + SeerProjectMutateRepoPayload, + SeerProjectRepoCreateInput, + SeerProjectReposResponse, +} from 'sentry/utils/seer/types'; + +function toOptimisticRepo( + repo: SeerProjectRepoCreateInput, + index: number, + cachedRepo: Repository | undefined +): SeerProjectReposResponse { + // See also: src/sentry/seer/endpoints/project_seer_repos.py::_serialize_project_repo() + const repoFullName = cachedRepo?.name || ''; + const slashIndex = repoFullName.indexOf('/'); + const owner = slashIndex >= 0 ? repoFullName.slice(0, slashIndex) : ''; + const name = slashIndex >= 0 ? repoFullName.slice(slashIndex + 1) : repoFullName; + return { + id: `optimistic-${index}-${Date.now()}`, + repositoryId: String(repo.repositoryId), + branchName: repo.branchName ?? '', + branchOverrides: (repo.branchOverrides ?? []).map((o, i) => ({ + ...o, + id: String(i), + })), + instructions: repo.instructions ?? '', + externalId: cachedRepo?.externalId ?? '', + integrationId: cachedRepo?.integrationId ?? '', + name: name || cachedRepo?.name || '', + organizationId: '', + owner: owner || '', + provider: cachedRepo?.provider?.name?.toLowerCase() ?? '', + }; +} + +function getRepoLookupFromCache( + queryClient: QueryClient, + organization: Organization +): Map { + const options = organizationRepositoriesInfiniteOptions({organization}); + const cached = queryClient.getQueryData(options.queryKey); + const lookup = new Map(); + if (cached) { + for (const page of cached.pages) { + for (const repo of page.json) { + lookup.set(repo.id, repo); + } + } + } + return lookup; +} + +function getSeerProjectRepoQueryOptions({ + organization, + project, + repoId, +}: { + organization: Organization; + project: AvatarProject; + repoId: string; +}) { + return apiOptions.as()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/repos/$repoId/', + { + path: { + organizationIdOrSlug: organization.slug, + projectIdOrSlug: project.slug, + repoId, + }, + staleTime: 60_000, // 1 minute + } + ); +} + +export function getMutateSeerProjectRepoOptions({ + organization, + project, + queryClient, + repoId, +}: { + organization: Organization; + project: AvatarProject; + queryClient: QueryClient; + repoId: string; +}) { + const singleQueryKey = getSeerProjectRepoQueryOptions({ + organization, + project, + repoId, + }).queryKey; + const [singleUrl] = singleQueryKey; + + const infiniteQueryKey = getSeerProjectReposInfiniteQueryOptions({ + organization, + project, + }).queryKey; + const [infiniteUrl] = infiniteQueryKey; + + return mutationOptions({ + mutationFn: (data: SeerProjectMutateRepoPayload) => { + return fetchMutation({ + method: 'PUT', + url: singleUrl, + data, + }); + }, + onMutate: async (data: SeerProjectMutateRepoPayload) => { + await queryClient.cancelQueries({queryKey: singleQueryKey}); + await queryClient.cancelQueries({queryKey: [infiniteUrl], exact: false}); + + const previousSingle = queryClient.getQueryData(singleQueryKey); + const previousInfinite = queryClient.getQueryData(infiniteQueryKey); + + const jsonUpdates: Partial = {}; + if (data.branchName !== undefined) { + jsonUpdates.branchName = data.branchName ?? ''; + } + if (data.branchOverrides !== undefined) { + jsonUpdates.branchOverrides = data.branchOverrides.map((o, i) => ({ + ...o, + id: String(i), + })); + } + if (data.instructions !== undefined) { + jsonUpdates.instructions = data.instructions ?? ''; + } + + queryClient.setQueryData( + singleQueryKey, + (prev: ApiResponse | undefined) => { + if (prev) { + return {...prev, json: {...prev.json, ...jsonUpdates}}; + } + return prev; + } + ); + + queryClient.setQueriesData( + {queryKey: [infiniteUrl], exact: false}, + (prev: InfiniteData> | undefined) => { + if (prev) { + return { + ...prev, + pages: prev.pages.map(page => ({ + ...page, + json: page.json.map(item => + item.repositoryId === repoId ? {...item, ...jsonUpdates} : item + ), + })), + }; + } + return prev; + } + ); + + return {previousSingle, previousInfinite, infiniteUrl}; + }, + onError: (_error, _data, context) => { + queryClient.setQueryData(singleQueryKey, context?.previousSingle); + if (context?.infiniteUrl) { + queryClient.invalidateQueries({queryKey: [context.infiniteUrl], exact: false}); + } + }, + onSettled: () => { + queryClient.invalidateQueries({queryKey: singleQueryKey}); + queryClient.invalidateQueries({queryKey: [infiniteUrl], exact: false}); + }, + }); +} + +export function getDeleteSeerProjectRepoOptions({ + organization, + project, + queryClient, +}: { + organization: Organization; + project: AvatarProject; + queryClient: QueryClient; +}) { + const infiniteQueryKey = getSeerProjectReposInfiniteQueryOptions({ + organization, + project, + }).queryKey; + const [infiniteUrl] = infiniteQueryKey; + + return mutationOptions({ + mutationFn: ({repoId}: {repoId: string}) => { + const [singleUrl] = getSeerProjectRepoQueryOptions({ + organization, + project, + repoId, + }).queryKey; + + return fetchMutation({ + method: 'DELETE', + url: singleUrl, + data: {repoId}, + }); + }, + onMutate: async ({repoId}: {repoId: string}) => { + const singleQueryKey = getSeerProjectRepoQueryOptions({ + organization, + project, + repoId, + }).queryKey; + + await queryClient.cancelQueries({queryKey: singleQueryKey}); + await queryClient.cancelQueries({queryKey: [infiniteUrl], exact: false}); + + const previousSingle = queryClient.getQueryData(singleQueryKey); + const previousInfinite = queryClient.getQueryData(infiniteQueryKey); + + queryClient.removeQueries({queryKey: singleQueryKey}); + + queryClient.setQueriesData( + {queryKey: [infiniteUrl], exact: false}, + (prev: InfiniteData> | undefined) => { + if (prev) { + return { + ...prev, + pages: prev.pages.map(page => ({ + ...page, + json: page.json.filter( + (item: SeerProjectReposResponse) => item.repositoryId !== repoId + ), + })), + }; + } + return prev; + } + ); + + return {previousSingle, previousInfinite, singleQueryKey, infiniteUrl}; + }, + onError: (_error, _data, context) => { + if (context?.singleQueryKey) { + queryClient.setQueryData(context.singleQueryKey, context.previousSingle); + } + if (context?.infiniteUrl) { + queryClient.invalidateQueries({queryKey: [context.infiniteUrl], exact: false}); + } + }, + onSettled: (_data, _error, _variables, context) => { + if (context?.singleQueryKey) { + queryClient.invalidateQueries({queryKey: context.singleQueryKey}); + } + queryClient.invalidateQueries({queryKey: [infiniteUrl], exact: false}); + }, + }); +} + +export function getSeerProjectReposInfiniteQueryOptions({ + organization, + project, +}: { + organization: Organization; + project: AvatarProject; +}) { + return apiOptions.asInfinite()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/repos/', + { + path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: project.slug}, + staleTime: 60_000, // 1 minute + } + ); +} + +export function getMutateSeerProjectReposOptionsAddRepo({ + organization, + project, + queryClient, +}: { + organization: Organization; + project: AvatarProject; + queryClient: QueryClient; +}) { + const infiniteQueryKey = getSeerProjectReposInfiniteQueryOptions({ + organization, + project, + }).queryKey; + const [infiniteUrl] = infiniteQueryKey; + + return mutationOptions({ + mutationFn: (data: {repos: SeerProjectRepoCreateInput[]}) => { + return fetchMutation({ + method: 'POST', + url: infiniteUrl, + data, + }); + }, + onMutate: async (data: {repos: SeerProjectRepoCreateInput[]}) => { + await queryClient.cancelQueries({queryKey: [infiniteUrl], exact: false}); + + const previousInfinite = queryClient.getQueryData(infiniteQueryKey); + const repoLookup = getRepoLookupFromCache(queryClient, organization); + const optimisticItems = data.repos.map((repo, index) => + toOptimisticRepo(repo, index, repoLookup.get(String(repo.repositoryId))) + ); + + queryClient.setQueriesData( + {queryKey: [infiniteUrl], exact: false}, + (prev: InfiniteData> | undefined) => { + if (prev && prev.pages.length > 0) { + const lastPageIndex = prev.pages.length - 1; + return { + ...prev, + pages: prev.pages.map((page, i) => + i === lastPageIndex + ? {...page, json: [...page.json, ...optimisticItems]} + : page + ), + }; + } + return prev; + } + ); + + return {previousInfinite, infiniteUrl}; + }, + onError: (_error, _data, context) => { + if (context?.infiniteUrl) { + queryClient.invalidateQueries({queryKey: [context.infiniteUrl], exact: false}); + } + }, + onSettled: (_data, _error, variables, _context) => { + for (const repo of variables.repos) { + queryClient.invalidateQueries({ + queryKey: getSeerProjectRepoQueryOptions({ + organization, + project, + repoId: repo.repositoryId.toString(), + }).queryKey, + }); + } + queryClient.invalidateQueries({queryKey: [infiniteUrl], exact: false}); + }, + }); +} diff --git a/static/app/utils/seer/types.ts b/static/app/utils/seer/types.ts index 397e3ea7a97e83..308e5a60cef81c 100644 --- a/static/app/utils/seer/types.ts +++ b/static/app/utils/seer/types.ts @@ -60,3 +60,41 @@ export type SeerProjectSettingResponse = { scannerAutomation: boolean; stoppingPoint: SeerAutofixStoppingPoint; }; + +type BranchOverrideInput = { + branchName: string; + tagName: string; + tagValue: string; +}; + +export type SeerProjectRepoCreateInput = { + repositoryId: string; + branchName?: string | null; + branchOverrides?: BranchOverrideInput[]; + instructions?: string | null; +}; + +export type SeerProjectMutateRepoPayload = { + branchName?: string | null; + branchOverrides?: BranchOverrideInput[]; + instructions?: string | null; +}; + +export type SeerProjectReposResponse = { + branchName: string; + branchOverrides: Array<{ + branchName: string; + id: string; + tagName: string; + tagValue: string; + }>; + externalId: string; + id: string; + instructions: string; + integrationId: string; + name: string; + organizationId: string; + owner: string; + provider: string; + repositoryId: string; +}; diff --git a/static/app/views/settings/projectSeer/index.spec.tsx b/static/app/views/settings/projectSeer/index.spec.tsx index dfe7a8fb390544..a890c9a991fca6 100644 --- a/static/app/views/settings/projectSeer/index.spec.tsx +++ b/static/app/views/settings/projectSeer/index.spec.tsx @@ -86,6 +86,22 @@ describe('ProjectSeer', () => { external_id: '101', }, ], + preference: { + repositories: [ + { + organization_id: 3, + external_id: '101', + name: 'sentry', + owner: 'getsentry', + provider: 'github', + integration_id: '201', + branch_name: '', + instructions: '', + branch_overrides: [], + }, + ], + automated_run_stopping_point: 'root_cause', + }, }; MockApiClient.addMockResponse({ @@ -101,6 +117,26 @@ describe('ProjectSeer', () => { integrations: [], }, }); + + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, + method: 'GET', + body: [ + { + id: '1', + repositoryId: '1', + branchName: '', + branchOverrides: [], + instructions: '', + externalId: '101', + integrationId: '201', + name: 'sentry', + organizationId: '', + owner: 'getsentry', + provider: 'github', + }, + ], + }); }); afterEach(() => { @@ -108,8 +144,8 @@ describe('ProjectSeer', () => { }); it('can add a repository', async () => { - const seerPreferencesPostRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + const seerReposPostRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, method: 'POST', }); @@ -124,7 +160,9 @@ describe('ProjectSeer', () => { expect(screen.queryByText('getsentry/seer')).not.toBeInTheDocument(); // Open the add repo modal - await userEvent.click(screen.getByRole('button', {name: 'Add Repos'})); + await userEvent.click( + screen.getByRole('button', {name: 'Add Repositories to Project'}) + ); // Find and select the unselected repo in the modal const modal = await screen.findByRole('dialog'); @@ -134,24 +172,36 @@ describe('ProjectSeer', () => { // Override GET mock to return updated data before mutation triggers refetch MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, method: 'GET', - body: { - code_mapping_repos: [ - { - provider: 'github', - owner: 'getsentry', - name: 'sentry', - external_id: '101', - }, - { - provider: 'github', - owner: 'getsentry', - name: 'seer', - external_id: '102', - }, - ], - }, + body: [ + { + id: '1', + repositoryId: '1', + branchName: '', + branchOverrides: [], + instructions: '', + externalId: '101', + integrationId: '201', + name: 'sentry', + organizationId: '', + owner: 'getsentry', + provider: 'github', + }, + { + id: '2', + repositoryId: '2', + branchName: '', + branchOverrides: [], + instructions: '', + externalId: '102', + integrationId: '202', + name: 'seer', + organizationId: '', + owner: 'getsentry', + provider: 'github', + }, + ], }); // Save changes in the modal @@ -164,46 +214,26 @@ describe('ProjectSeer', () => { expect(await screen.findByText('getsentry/seer')).toBeInTheDocument(); await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + expect(seerReposPostRequest).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ data: expect.objectContaining({ - automated_run_stopping_point: 'root_cause', - repositories: [ - { - organization_id: 3, - branch_name: '', - external_id: '101', - instructions: '', - name: 'sentry', - owner: 'getsentry', - provider: 'github', - integration_id: '201', - branch_overrides: [], - }, - { - organization_id: 3, - branch_name: '', - external_id: '102', - instructions: '', - name: 'seer', - owner: 'getsentry', - provider: 'github', - integration_id: '202', - branch_overrides: [], - }, + repos: [ + expect.objectContaining({ + repositoryId: '2', + }), ], }), }) ); }); - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + expect(seerReposPostRequest).toHaveBeenCalledTimes(1); }); it('can update repository settings', async () => { - const seerPreferencesPostRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'POST', + const seerRepoPutRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/1/`, + method: 'PUT', }); render(, { @@ -217,47 +247,27 @@ describe('ProjectSeer', () => { // Expand the repo item await userEvent.click(repoItem); - // Find input fields + // Find input field and type a branch name (auto-saves via debounce) const branchInput = screen.getByPlaceholderText('Default branch'); - const instructionsInput = screen.getByPlaceholderText( - 'Add any general context or instructions to help Seer understand this repository...' - ); - await userEvent.type(branchInput, 'develop'); - await userEvent.type(instructionsInput, 'Use Conventional Commits'); - - await userEvent.click(screen.getByRole('button', {name: 'Save'})); + await userEvent.tab(); // blur triggers AutoSaveForm submit await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + expect(seerRepoPutRequest).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ data: expect.objectContaining({ - automated_run_stopping_point: 'root_cause', - repositories: [ - { - organization_id: 3, - external_id: '101', - name: 'sentry', - owner: 'getsentry', - provider: 'github', - branch_name: 'develop', - instructions: 'Use Conventional Commits', - integration_id: '201', - branch_overrides: [], - }, - ], + branchName: 'develop', }), }) ); }); - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); }); it('can remove a repository', async () => { - const seerPreferencesPostRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - method: 'POST', + const seerRepoDeleteRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/1/`, + method: 'DELETE', }); render(, { @@ -271,16 +281,14 @@ describe('ProjectSeer', () => { // Open the row and click remove await userEvent.click(repoItem); - // Override GET mock to return updated data before mutation triggers refetch + // Override GET mock to return empty list after deletion MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, method: 'GET', - body: { - code_mapping_repos: [], - }, + body: [], }); - await userEvent.click(screen.getByRole('button', {name: 'Remove Repository'})); + await userEvent.click(screen.getByRole('button', {name: 'Disconnect Repository'})); await userEvent.click(await screen.findByRole('button', {name: 'Confirm'})); @@ -289,18 +297,7 @@ describe('ProjectSeer', () => { expect(screen.queryByText('getsentry/sentry')).not.toBeInTheDocument(); }); - await waitFor(() => { - expect(seerPreferencesPostRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: expect.objectContaining({ - automated_run_stopping_point: 'root_cause', - repositories: [], - }), - }) - ); - }); - expect(seerPreferencesPostRequest).toHaveBeenCalledTimes(1); + expect(seerRepoDeleteRequest).toHaveBeenCalledTimes(1); }); it('can update the autofix autorun threshold setting', async () => { @@ -721,6 +718,12 @@ describe('ProjectSeer', () => { body: initialProject, }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, method: 'GET', @@ -804,6 +807,12 @@ describe('ProjectSeer', () => { body: initialProject, }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, method: 'GET', @@ -916,6 +925,12 @@ describe('ProjectSeer', () => { body: initialProject, }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, method: 'GET', @@ -1010,6 +1025,12 @@ describe('ProjectSeer', () => { body: initialProject, }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, method: 'GET', @@ -1133,6 +1154,12 @@ describe('ProjectSeer', () => { body: initialProject, }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/setup-check/`, method: 'GET', @@ -1219,6 +1246,12 @@ describe('ProjectSeer', () => { body: initialProject, }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/repos/`, + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, method: 'GET', @@ -1320,11 +1353,13 @@ describe('ProjectSeer', () => { }); renderGlobalModal({organization: orgWithGitlabSupport}); - // Wait for repos to load (sentry is pre-selected via code_mapping_repos in beforeEach) + // Wait for repos to load (sentry is pre-selected via preference.repositories in beforeEach) expect(await screen.findByText('getsentry/sentry')).toBeInTheDocument(); // Open the add repo modal — it shows only unselected repos - await userEvent.click(screen.getByRole('button', {name: 'Add Repos'})); + await userEvent.click( + screen.getByRole('button', {name: 'Add Repositories to Project'}) + ); const modal = await screen.findByRole('dialog'); @@ -1354,11 +1389,13 @@ describe('ProjectSeer', () => { }); renderGlobalModal(); - // Wait for repos to load (sentry is pre-selected via code_mapping_repos in beforeEach) + // Wait for repos to load (sentry is pre-selected via preference.repositories in beforeEach) expect(await screen.findByText('getsentry/sentry')).toBeInTheDocument(); // Open the add repo modal — it shows only unselected repos - await userEvent.click(screen.getByRole('button', {name: 'Add Repos'})); + await userEvent.click( + screen.getByRole('button', {name: 'Add Repositories to Project'}) + ); const modal = await screen.findByRole('dialog'); diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index e04b8004dc926d..9452a873c3ea09 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -29,8 +29,8 @@ import {ExternalLink} from 'sentry/components/links/externalLink'; import {NoAccess} from 'sentry/components/noAccess'; import {OverrideOrDefault} from 'sentry/components/overrideOrDefault'; import {Placeholder} from 'sentry/components/placeholder'; -import {AutofixRepositories} from 'sentry/components/seer/legacy/autofixRepositories'; import {SEER_THRESHOLD_OPTIONS} from 'sentry/components/seer/legacy/constants'; +import {AutofixRepositoriesList} from 'sentry/components/seer/projectDetails/autofixRepositoriesList'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; import {ProjectsStore} from 'sentry/stores/projectsStore'; @@ -548,7 +548,7 @@ function ProjectSeer({ - + ( { + hiddenExternalIds={currentRepoIds} + onSave={({selectedExternalIds}) => { const reposData = transformRepositoriesToApiFormat( repositories, organization.id, - repoIds + [...currentRepoIds, ...selectedExternalIds] ); updateProjectSeerPreferences({repositories: reposData});