Skip to content

Commit 165cfc1

Browse files
jaydgossclaudegetsantry[bot]
authored
feat(onboarding): Implement SCM_CONNECT step with provider connection and repo selection (#110883)
## Summary Implements the `SCM_CONNECT` onboarding step behind the `organizations:onboarding-scm` feature flag. Functional-first -- UI polish is out of scope. ## What it does **Provider connection** - Renders OAuth buttons for each SCM provider (GitHub, GitLab, Bitbucket, Azure DevOps) - Auto-selects an existing integration on mount so returning users skip to the connected view - Stores the `Integration` in `OnboardingContext.selectedIntegration` **Repository selection** - Debounced search dropdown querying `GET /integrations/{id}/repos/?search=` - Selecting a repo registers it in Sentry via `POST /repos/` (or looks up the existing Sentry ID if already added) - Switching repos cleans up the previously added one - Removing a repo deletes it from Sentry if we added it during this session - Stores `Repository` (with real Sentry ID) in `OnboardingContext.selectedRepository` - Continue is disabled until a repo is selected **PRD scenario alignment** - Scenario A (new org): provider pills with OAuth - Scenario B (existing SCM): auto-selects, shows connected view with repo search - Scenario D/E/F (add new org, switch provider, remove SCM): not supported in onboarding per PRD, "Manage in Settings" link provided **Skip and navigation** - "Skip for now" advances to next step without SCM data - Selections restore from context when navigating back ## Out of scope - UI polish, animations, "Why connect" benefits card - Repo relevance ordering - Multiple simultaneous integrations - Explicit error states for popup closed / OAuth failure ## New files - `useScmProviders.ts` -- data-fetching hook for SCM providers and active installations - `components/scmProviderPills.tsx` -- OAuth provider pill buttons - `components/scmConnectedView.tsx` -- connected status + "Manage in Settings" link + repo selector - `components/scmRepoSelector.tsx` -- repo search dropdown UI - `components/useRepoSearch.ts` -- debounced provider repo search query - `components/useRepoSelection.ts` -- repo add/remove lifecycle with optimistic updates ## Test plan - [x] 22 onboarding tests passing - [x] Auto-selects existing integration and shows connected view - [x] "Skip for now" advances to next step - [x] Manual: OAuth flow connects provider and shows connected view - [x] Manual: Repo search, selection, and removal work end-to-end - [x] Manual: Switching repos cleans up the previously added one Refs VDY-19 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 588d0c0 commit 165cfc1

File tree

11 files changed

+1055
-61
lines changed

11 files changed

+1055
-61
lines changed

static/app/components/onboarding/onboardingContext.tsx

Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ type OnboardingContextProps = {
99
setSelectedFeatures: (features?: ProductSolution[]) => void;
1010
setSelectedIntegration: (integration?: Integration) => void;
1111
setSelectedPlatform: (selectedSDK?: OnboardingSelectedSDK) => void;
12-
setSelectedRepositories: (repos?: Repository[]) => void;
12+
setSelectedRepository: (repo?: Repository) => void;
1313
selectedFeatures?: ProductSolution[];
1414
selectedIntegration?: Integration;
1515
selectedPlatform?: OnboardingSelectedSDK;
16-
selectedRepositories?: Repository[];
16+
selectedRepository?: Repository;
1717
};
1818

19-
type OnboardingSessionState = {
19+
export type OnboardingSessionState = {
2020
selectedFeatures?: ProductSolution[];
2121
selectedIntegration?: Integration;
2222
selectedPlatform?: OnboardingSelectedSDK;
23-
selectedRepositories?: Repository[];
23+
selectedRepository?: Repository;
2424
};
2525

2626
/**
@@ -31,63 +31,47 @@ const OnboardingContext = createContext<OnboardingContextProps>({
3131
setSelectedPlatform: () => {},
3232
selectedIntegration: undefined,
3333
setSelectedIntegration: () => {},
34-
selectedRepositories: undefined,
35-
setSelectedRepositories: () => {},
34+
selectedRepository: undefined,
35+
setSelectedRepository: () => {},
3636
selectedFeatures: undefined,
3737
setSelectedFeatures: () => {},
3838
});
3939

4040
type ProviderProps = {
4141
children: React.ReactNode;
4242
/**
43-
* This is only used in our frontend tests to set the initial value of the context.
43+
* Optional initial session state. Primarily used in tests to seed the context
44+
* without touching session storage directly.
4445
*/
45-
value?: Pick<OnboardingContextProps, 'selectedPlatform'>;
46+
initialValue?: OnboardingSessionState;
4647
};
4748

48-
export function OnboardingContextProvider({children, value}: ProviderProps) {
49+
export function OnboardingContextProvider({children, initialValue}: ProviderProps) {
4950
const [onboarding, setOnboarding, removeOnboarding] = useSessionStorage<
5051
OnboardingSessionState | undefined
51-
>(
52-
'onboarding',
53-
value?.selectedPlatform ? {selectedPlatform: value.selectedPlatform} : undefined
54-
);
52+
>('onboarding', initialValue);
5553

5654
const contextValue = useMemo(
5755
() => ({
5856
selectedPlatform: onboarding?.selectedPlatform,
5957
setSelectedPlatform: (selectedPlatform?: OnboardingSelectedSDK) => {
6058
if (selectedPlatform === undefined) {
61-
// Clear platform but preserve other SCM state (integration, repos, features).
62-
// Full reset only happens if no other state remains.
63-
const nextState = {
64-
...onboarding,
65-
selectedPlatform: undefined,
66-
};
67-
const hasOtherState =
68-
nextState.selectedIntegration ||
69-
nextState.selectedRepositories ||
70-
nextState.selectedFeatures;
71-
if (hasOtherState) {
72-
setOnboarding(nextState);
73-
} else {
74-
removeOnboarding();
75-
}
59+
removeOnboarding();
7660
} else {
77-
setOnboarding({...onboarding, selectedPlatform});
61+
setOnboarding(prev => ({...prev, selectedPlatform}));
7862
}
7963
},
8064
selectedIntegration: onboarding?.selectedIntegration,
8165
setSelectedIntegration: (selectedIntegration?: Integration) => {
82-
setOnboarding({...onboarding, selectedIntegration});
66+
setOnboarding(prev => ({...prev, selectedIntegration}));
8367
},
84-
selectedRepositories: onboarding?.selectedRepositories,
85-
setSelectedRepositories: (selectedRepositories?: Repository[]) => {
86-
setOnboarding({...onboarding, selectedRepositories});
68+
selectedRepository: onboarding?.selectedRepository,
69+
setSelectedRepository: (selectedRepository?: Repository) => {
70+
setOnboarding(prev => ({...prev, selectedRepository}));
8771
},
8872
selectedFeatures: onboarding?.selectedFeatures,
8973
setSelectedFeatures: (selectedFeatures?: ProductSolution[]) => {
90-
setOnboarding({...onboarding, selectedFeatures});
74+
setOnboarding(prev => ({...prev, selectedFeatures}));
9175
},
9276
}),
9377
[onboarding, setOnboarding, removeOnboarding]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {Flex} from '@sentry/scraps/layout';
2+
3+
import type {Integration, IntegrationProvider} from 'sentry/types/integrations';
4+
import {getIntegrationIcon} from 'sentry/utils/integrationUtil';
5+
import {IntegrationButton} from 'sentry/views/settings/organizationIntegrations/integrationButton';
6+
import {IntegrationContext} from 'sentry/views/settings/organizationIntegrations/integrationContext';
7+
8+
interface ScmProviderPillsProps {
9+
onInstall: (data: Integration) => void;
10+
providers: IntegrationProvider[];
11+
}
12+
13+
export function ScmProviderPills({providers, onInstall}: ScmProviderPillsProps) {
14+
return (
15+
<Flex gap="md" wrap="wrap" justify="center">
16+
{providers.map(provider => (
17+
<IntegrationContext
18+
key={provider.key}
19+
value={{
20+
provider,
21+
type: 'first_party',
22+
installStatus: 'Not Installed',
23+
analyticsParams: {
24+
view: 'onboarding',
25+
already_installed: false,
26+
},
27+
}}
28+
>
29+
<IntegrationButton
30+
userHasAccess
31+
onAddIntegration={onInstall}
32+
onExternalClick={() => {}}
33+
buttonProps={{
34+
size: 'sm',
35+
icon: getIntegrationIcon(provider.key, 'sm'),
36+
buttonText: provider.name,
37+
}}
38+
/>
39+
</IntegrationContext>
40+
))}
41+
</Flex>
42+
);
43+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {Button} from '@sentry/scraps/button';
2+
import {CompactSelect} from '@sentry/scraps/compactSelect';
3+
import {Flex, Stack} from '@sentry/scraps/layout';
4+
import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
5+
import {Text} from '@sentry/scraps/text';
6+
7+
import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext';
8+
import {IconClose} from 'sentry/icons';
9+
import {t} from 'sentry/locale';
10+
11+
import {useScmRepoSearch} from './useScmRepoSearch';
12+
import {useScmRepoSelection} from './useScmRepoSelection';
13+
14+
export function ScmRepoSelector() {
15+
const {selectedIntegration, selectedRepository, setSelectedRepository} =
16+
useOnboardingContext();
17+
const {reposByIdentifier, dropdownItems, isFetching, debouncedSearch, setSearch} =
18+
useScmRepoSearch(selectedIntegration?.id ?? '', selectedRepository);
19+
20+
const {busy, handleSelect, handleRemove} = useScmRepoSelection({
21+
onSelect: setSelectedRepository,
22+
reposByIdentifier,
23+
});
24+
25+
return (
26+
<Stack gap="md">
27+
<CompactSelect
28+
menuWidth="100%"
29+
disabled={busy}
30+
options={dropdownItems}
31+
onChange={handleSelect}
32+
value={undefined}
33+
menuTitle={t('Repositories')}
34+
emptyMessage={
35+
isFetching
36+
? t('Searching\u2026')
37+
: debouncedSearch
38+
? t('No repositories found.')
39+
: t('Type to search repositories')
40+
}
41+
search={{
42+
placeholder: t('Search repositories'),
43+
filter: false,
44+
onChange: setSearch,
45+
}}
46+
loading={isFetching}
47+
trigger={triggerProps => (
48+
<OverlayTrigger.Button {...triggerProps} busy={busy}>
49+
{selectedRepository ? selectedRepository.name : t('Search repositories')}
50+
</OverlayTrigger.Button>
51+
)}
52+
/>
53+
{selectedRepository && (
54+
<Flex align="center" gap="sm">
55+
<Flex flexGrow={1}>
56+
<Text size="sm">{selectedRepository.name}</Text>
57+
</Flex>
58+
<Button
59+
size="zero"
60+
priority="link"
61+
icon={<IconClose size="xs" />}
62+
aria-label={t('Remove %s', selectedRepository.name)}
63+
onClick={handleRemove}
64+
disabled={busy}
65+
/>
66+
</Flex>
67+
)}
68+
</Stack>
69+
);
70+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {useMemo, useState} from 'react';
2+
3+
import type {IntegrationRepository, Repository} from 'sentry/types/integrations';
4+
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
5+
import {fetchDataQuery, useQuery} from 'sentry/utils/queryClient';
6+
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
7+
import {useOrganization} from 'sentry/utils/useOrganization';
8+
9+
interface ScmRepoSearchResult {
10+
repos: IntegrationRepository[];
11+
}
12+
13+
export function useScmRepoSearch(integrationId: string, selectedRepo?: Repository) {
14+
const organization = useOrganization();
15+
const [search, setSearch] = useState('');
16+
const debouncedSearch = useDebouncedValue(search, 200);
17+
18+
const searchQuery = useQuery({
19+
queryKey: [
20+
getApiUrl(
21+
`/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/`,
22+
{
23+
path: {
24+
organizationIdOrSlug: organization.slug,
25+
integrationId,
26+
},
27+
}
28+
),
29+
{method: 'GET', query: {search: debouncedSearch}},
30+
] as const,
31+
queryFn: async context => {
32+
return fetchDataQuery<ScmRepoSearchResult>(context);
33+
},
34+
retry: 0,
35+
staleTime: 20_000,
36+
placeholderData: previousData => (debouncedSearch ? previousData : undefined),
37+
enabled: !!debouncedSearch,
38+
});
39+
40+
const selectedRepoSlug = selectedRepo?.externalSlug;
41+
42+
const {reposByIdentifier, dropdownItems} = useMemo(
43+
() =>
44+
(searchQuery.data?.[0]?.repos ?? []).reduce<{
45+
dropdownItems: Array<{
46+
disabled: boolean;
47+
label: string;
48+
textValue: string;
49+
value: string;
50+
}>;
51+
reposByIdentifier: Map<string, IntegrationRepository>;
52+
}>(
53+
(acc, repo) => {
54+
acc.reposByIdentifier.set(repo.identifier, repo);
55+
acc.dropdownItems.push({
56+
value: repo.identifier,
57+
label: repo.name,
58+
textValue: repo.name,
59+
disabled: repo.identifier === selectedRepoSlug,
60+
});
61+
return acc;
62+
},
63+
{
64+
reposByIdentifier: new Map(),
65+
dropdownItems: [],
66+
}
67+
),
68+
[searchQuery.data, selectedRepoSlug]
69+
);
70+
71+
return {
72+
reposByIdentifier,
73+
dropdownItems,
74+
isFetching: searchQuery.isFetching,
75+
debouncedSearch,
76+
setSearch,
77+
};
78+
}

0 commit comments

Comments
 (0)