Skip to content

Commit 7ba5bb6

Browse files
jaydgossdashed
authored andcommitted
fix(onboarding): Handle repo selection race with background link_all_repos (#111716)
Fix repo selection in the SCM onboarding connect step to handle the race with the background `link_all_repos` task. After a GitHub integration is installed, `link_all_repos` runs async and registers all repos. The old approach cached existing repos on mount and checked that cache before deciding to POST. This broke because the cache was often stale (task still running) and the repos endpoint is paginated (specific repo might not be on page 1). New approach: - GET first with a targeted query filtered by repo name (sidesteps pagination) - POST only if the repo doesn't exist yet The old code also hid (soft-deleted) repo records when the user deselected or switched repos. This made sense when repos were manually registered one at a time, but with `link_all_repos` auto-registering everything, hiding repos on deselect was counterproductive -- it removed records the background task created, causing them to disappear from the integration settings page. Repo selection is now just a context pointer with no side effects on the repo records themselves. Also switches to `queryClient.fetchQuery` + `fetchDataQuery` for the imperative GET instead of deprecated `Client.requestPromise`. Replaces #111699 (closed due to CODEOWNERS auto-review noise from base change).
1 parent f3dbc60 commit 7ba5bb6

File tree

2 files changed

+118
-207
lines changed

2 files changed

+118
-207
lines changed
Lines changed: 74 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,12 @@
11
import {OrganizationFixture} from 'sentry-fixture/organization';
22
import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
33

4-
import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';
4+
import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary';
55

6-
import {
7-
OnboardingContextProvider,
8-
type OnboardingSessionState,
9-
} from 'sentry/components/onboarding/onboardingContext';
106
import type {IntegrationRepository} from 'sentry/types/integrations';
11-
import {sessionStorageWrapper} from 'sentry/utils/sessionStorage';
127

138
import {useScmRepoSelection} from './useScmRepoSelection';
149

15-
function makeOnboardingWrapper(initialState?: OnboardingSessionState) {
16-
return function OnboardingWrapper({children}: {children?: React.ReactNode}) {
17-
return (
18-
<OnboardingContextProvider initialValue={initialState}>
19-
{children}
20-
</OnboardingContextProvider>
21-
);
22-
};
23-
}
24-
2510
describe('useScmRepoSelection', () => {
2611
const organization = OrganizationFixture();
2712

@@ -49,29 +34,56 @@ describe('useScmRepoSelection', () => {
4934
isInstalled: false,
5035
};
5136

52-
const mockInstalledRepo: IntegrationRepository = {
53-
identifier: 'getsentry/sentry',
54-
name: 'sentry',
55-
isInstalled: true,
56-
};
57-
5837
beforeEach(() => {
59-
sessionStorageWrapper.clear();
6038
onSelect = jest.fn();
6139
reposByIdentifier = new Map([['getsentry/sentry', mockRepo]]);
40+
});
6241

63-
// Default: no existing repos
42+
afterEach(() => {
43+
MockApiClient.clearMockResponses();
44+
});
45+
46+
it('uses existing repo when GET finds it, skips POST', async () => {
6447
MockApiClient.addMockResponse({
6548
url: `/organizations/${organization.slug}/repos/`,
66-
body: [],
49+
body: [
50+
{
51+
id: '99',
52+
name: 'getsentry/sentry',
53+
externalSlug: 'getsentry/sentry',
54+
status: 'active',
55+
},
56+
],
6757
});
68-
});
6958

70-
afterEach(() => {
71-
MockApiClient.clearMockResponses();
59+
const addRequest = MockApiClient.addMockResponse({
60+
url: `/organizations/${organization.slug}/repos/`,
61+
method: 'POST',
62+
body: {},
63+
});
64+
65+
const {result} = renderHookWithProviders(
66+
() =>
67+
useScmRepoSelection({integration: mockIntegration, onSelect, reposByIdentifier}),
68+
{organization}
69+
);
70+
71+
await act(async () => {
72+
await result.current.handleSelect({value: 'getsentry/sentry'});
73+
});
74+
75+
expect(addRequest).not.toHaveBeenCalled();
76+
expect(onSelect).toHaveBeenCalledWith(
77+
expect.objectContaining({id: '99', name: 'getsentry/sentry'})
78+
);
7279
});
7380

74-
it('calls POST to add new repo and updates onSelect with server ID', async () => {
81+
it('creates repo via POST when GET finds nothing', async () => {
82+
MockApiClient.addMockResponse({
83+
url: `/organizations/${organization.slug}/repos/`,
84+
body: [],
85+
});
86+
7587
const addRequest = MockApiClient.addMockResponse({
7688
url: `/organizations/${organization.slug}/repos/`,
7789
method: 'POST',
@@ -86,10 +98,7 @@ describe('useScmRepoSelection', () => {
8698
const {result} = renderHookWithProviders(
8799
() =>
88100
useScmRepoSelection({integration: mockIntegration, onSelect, reposByIdentifier}),
89-
{
90-
organization,
91-
additionalWrapper: makeOnboardingWrapper({}),
92-
}
101+
{organization}
93102
);
94103

95104
await act(async () => {
@@ -101,171 +110,104 @@ describe('useScmRepoSelection', () => {
101110
expect(onSelect).toHaveBeenCalledWith(
102111
expect.objectContaining({id: '', name: 'sentry'})
103112
);
104-
// Then real call with server response spread over optimistic
113+
// Then real call with server response
105114
expect(onSelect).toHaveBeenCalledWith(
106115
expect.objectContaining({id: '42', name: 'getsentry/sentry'})
107116
);
108117
});
109118

110-
it('does not POST for already-installed repos, uses existing repo ID', async () => {
111-
reposByIdentifier = new Map([['getsentry/sentry', mockInstalledRepo]]);
112-
113-
// Override repos response with an existing repo
114-
MockApiClient.clearMockResponses();
119+
it('reverts onSelect on POST failure', async () => {
115120
MockApiClient.addMockResponse({
116121
url: `/organizations/${organization.slug}/repos/`,
117-
body: [
118-
{
119-
id: '99',
120-
name: 'getsentry/sentry',
121-
externalSlug: 'getsentry/sentry',
122-
status: 'active',
123-
},
124-
],
122+
body: [],
125123
});
126124

127-
const addRequest = MockApiClient.addMockResponse({
125+
MockApiClient.addMockResponse({
128126
url: `/organizations/${organization.slug}/repos/`,
129127
method: 'POST',
130-
body: {},
128+
statusCode: 500,
129+
body: {detail: 'Internal Error'},
131130
});
132131

133132
const {result} = renderHookWithProviders(
134133
() =>
135134
useScmRepoSelection({integration: mockIntegration, onSelect, reposByIdentifier}),
136-
{
137-
organization,
138-
additionalWrapper: makeOnboardingWrapper({}),
139-
}
135+
{organization}
140136
);
141137

142-
// Wait for existing repos query to resolve
143-
await waitFor(() => expect(result.current.busy).toBe(false));
144-
145138
await act(async () => {
146139
await result.current.handleSelect({value: 'getsentry/sentry'});
147140
});
148141

149-
expect(addRequest).not.toHaveBeenCalled();
142+
// Optimistic, then revert
150143
expect(onSelect).toHaveBeenCalledWith(
151-
expect.objectContaining({id: '99', name: 'getsentry/sentry'})
144+
expect.objectContaining({id: '', name: 'sentry'})
152145
);
146+
expect(onSelect).toHaveBeenCalledWith(undefined);
153147
});
154148

155-
it('reverts onSelect on addRepository failure', async () => {
149+
it('reverts onSelect on GET failure', async () => {
156150
MockApiClient.addMockResponse({
157151
url: `/organizations/${organization.slug}/repos/`,
158-
method: 'POST',
159152
statusCode: 500,
160153
body: {detail: 'Internal Error'},
161154
});
162155

163156
const {result} = renderHookWithProviders(
164157
() =>
165158
useScmRepoSelection({integration: mockIntegration, onSelect, reposByIdentifier}),
166-
{
167-
organization,
168-
additionalWrapper: makeOnboardingWrapper({}),
169-
}
159+
{organization}
170160
);
171161

172162
await act(async () => {
173163
await result.current.handleSelect({value: 'getsentry/sentry'});
174164
});
175165

176-
// Optimistic, then revert
177166
expect(onSelect).toHaveBeenCalledWith(
178167
expect.objectContaining({id: '', name: 'sentry'})
179168
);
180169
expect(onSelect).toHaveBeenCalledWith(undefined);
181170
});
182171

183-
it('cleans up previously added repo when selecting a new one', async () => {
184-
MockApiClient.addMockResponse({
185-
url: `/organizations/${organization.slug}/repos/`,
186-
method: 'POST',
187-
body: {
188-
id: '42',
189-
name: 'getsentry/sentry',
190-
externalSlug: 'getsentry/sentry',
191-
status: 'active',
192-
},
193-
});
194-
195-
const hideRequest = MockApiClient.addMockResponse({
196-
url: `/organizations/${organization.slug}/repos/42/`,
197-
method: 'PUT',
198-
body: {},
199-
});
200-
172+
it('handleRemove calls onSelect with undefined', () => {
201173
const {result} = renderHookWithProviders(
202174
() =>
203175
useScmRepoSelection({integration: mockIntegration, onSelect, reposByIdentifier}),
204-
{
205-
organization,
206-
additionalWrapper: makeOnboardingWrapper({}),
207-
}
176+
{organization}
208177
);
209178

210-
// First selection
211-
await act(async () => {
212-
await result.current.handleSelect({value: 'getsentry/sentry'});
179+
act(() => {
180+
result.current.handleRemove();
213181
});
214182

215-
// Add a second repo
216-
const secondRepo: IntegrationRepository = {
217-
identifier: 'getsentry/relay',
218-
name: 'relay',
219-
isInstalled: false,
220-
};
221-
reposByIdentifier.set('getsentry/relay', secondRepo);
183+
expect(onSelect).toHaveBeenCalledWith(undefined);
184+
});
222185

186+
it('clears busy state after selection completes', async () => {
223187
MockApiClient.addMockResponse({
224188
url: `/organizations/${organization.slug}/repos/`,
225-
method: 'POST',
226-
body: {
227-
id: '43',
228-
name: 'getsentry/relay',
229-
externalSlug: 'getsentry/relay',
230-
status: 'active',
231-
},
232-
});
233-
234-
// Second selection should clean up the first
235-
await act(async () => {
236-
await result.current.handleSelect({value: 'getsentry/relay'});
237-
});
238-
239-
expect(hideRequest).toHaveBeenCalled();
240-
});
241-
242-
it('does not call hideRepository on remove if repo was pre-existing', async () => {
243-
const hideRequest = MockApiClient.addMockResponse({
244-
url: `/organizations/${organization.slug}/repos/99/`,
245-
method: 'PUT',
246-
body: {},
189+
body: [
190+
{
191+
id: '99',
192+
name: 'getsentry/sentry',
193+
externalSlug: 'getsentry/sentry',
194+
status: 'active',
195+
},
196+
],
247197
});
248198

249199
const {result} = renderHookWithProviders(
250200
() =>
251201
useScmRepoSelection({integration: mockIntegration, onSelect, reposByIdentifier}),
252-
{
253-
organization,
254-
additionalWrapper: makeOnboardingWrapper({
255-
selectedRepository: {
256-
id: '99',
257-
name: 'sentry',
258-
externalSlug: 'getsentry/sentry',
259-
} as any,
260-
}),
261-
}
202+
{organization}
262203
);
263204

205+
expect(result.current.busy).toBe(false);
206+
264207
await act(async () => {
265-
await result.current.handleRemove();
208+
await result.current.handleSelect({value: 'getsentry/sentry'});
266209
});
267210

268-
expect(onSelect).toHaveBeenCalledWith(undefined);
269-
expect(hideRequest).not.toHaveBeenCalled();
211+
expect(result.current.busy).toBe(false);
270212
});
271213
});

0 commit comments

Comments
 (0)