Skip to content

Commit babbe06

Browse files
fix(project-creation): Rollback when rules fail to be created (#103208)
closes https://linear.app/getsentry/issue/TET-1369/user-confusion-when-project-creation-fails-but-succeeds
1 parent 2cfee3b commit babbe06

File tree

3 files changed

+179
-53
lines changed

3 files changed

+179
-53
lines changed

static/app/components/onboarding/useCreateProjectAndRules.ts

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {useCallback} from 'react';
2+
import * as Sentry from '@sentry/react';
3+
4+
import {removeProject} from 'sentry/actionCreators/projects';
15
import {useCreateProject} from 'sentry/components/onboarding/useCreateProject';
26
import {useCreateProjectRules} from 'sentry/components/onboarding/useCreateProjectRules';
37
import type {IssueAlertRule} from 'sentry/types/alerts';
@@ -6,6 +10,8 @@ import type {Project} from 'sentry/types/project';
610
import {defined} from 'sentry/utils';
711
import {useIsMutating, useMutation, useMutationState} from 'sentry/utils/queryClient';
812
import type RequestError from 'sentry/utils/requestError/requestError';
13+
import useApi from 'sentry/utils/useApi';
14+
import useOrganization from 'sentry/utils/useOrganization';
915
import type {useCreateNotificationAction} from 'sentry/views/projectInstall/issueAlertNotificationOptions';
1016
import type {RequestDataFragment} from 'sentry/views/projectInstall/issueAlertOptions';
1117

@@ -27,9 +33,40 @@ type Response = {
2733
notificationRule?: IssueAlertRule;
2834
};
2935

36+
function useRollbackProject() {
37+
const api = useApi();
38+
const organization = useOrganization();
39+
40+
return useCallback(
41+
async (project: Project) => {
42+
Sentry.logger.error('Rolling back project', {
43+
projectToRollback: project,
44+
});
45+
46+
try {
47+
// Rolling back the project also deletes its associated alert rules
48+
// due to the cascading delete constraint.
49+
await removeProject({
50+
api,
51+
orgSlug: organization.slug,
52+
projectSlug: project.slug,
53+
origin: 'getting_started',
54+
});
55+
} catch (err) {
56+
Sentry.withScope(scope => {
57+
scope.setExtra('error', err);
58+
Sentry.captureMessage('Failed to rollback project');
59+
});
60+
}
61+
},
62+
[api, organization.slug]
63+
);
64+
}
65+
3066
export function useCreateProjectAndRules() {
3167
const createProject = useCreateProject();
3268
const createProjectRules = useCreateProjectRules();
69+
const rollbackProject = useRollbackProject();
3370

3471
return useMutation<Response, RequestError, Variables>({
3572
mutationKey: [MUTATION_KEY],
@@ -47,34 +84,41 @@ export function useCreateProjectAndRules() {
4784
firstTeamSlug: team,
4885
});
4986

50-
const customRulePromise = alertRuleConfig?.shouldCreateCustomRule
51-
? createProjectRules.mutateAsync({
52-
projectSlug: project.slug,
53-
name: project.name,
54-
conditions: alertRuleConfig?.conditions,
55-
actions: alertRuleConfig?.actions,
56-
actionMatch: alertRuleConfig?.actionMatch,
57-
frequency: alertRuleConfig?.frequency,
58-
})
59-
: undefined;
60-
61-
const notificationRulePromise = createNotificationAction({
62-
shouldCreateRule: alertRuleConfig?.shouldCreateRule,
63-
name: project.name,
64-
projectSlug: project.slug,
65-
conditions: alertRuleConfig?.conditions,
66-
actionMatch: alertRuleConfig?.actionMatch,
67-
frequency: alertRuleConfig?.frequency,
68-
});
87+
try {
88+
const customRulePromise = alertRuleConfig?.shouldCreateCustomRule
89+
? createProjectRules.mutateAsync({
90+
projectSlug: project.slug,
91+
name: project.name,
92+
conditions: alertRuleConfig?.conditions,
93+
actions: alertRuleConfig?.actions,
94+
actionMatch: alertRuleConfig?.actionMatch,
95+
frequency: alertRuleConfig?.frequency,
96+
})
97+
: undefined;
98+
99+
const notificationRulePromise = createNotificationAction({
100+
shouldCreateRule: alertRuleConfig?.shouldCreateRule,
101+
name: project.name,
102+
projectSlug: project.slug,
103+
conditions: alertRuleConfig?.conditions,
104+
actionMatch: alertRuleConfig?.actionMatch,
105+
frequency: alertRuleConfig?.frequency,
106+
});
69107

70-
const [customRule, notificationRule] = await Promise.all([
71-
customRulePromise,
72-
notificationRulePromise,
73-
]);
108+
const [customRule, notificationRule] = await Promise.all([
109+
customRulePromise,
110+
notificationRulePromise,
111+
]);
74112

75-
const ruleIds = [customRule, notificationRule].filter(defined).map(rule => rule.id);
113+
const ruleIds = [customRule, notificationRule]
114+
.filter(defined)
115+
.map(rule => rule.id);
76116

77-
return {project, notificationRule, ruleIds};
117+
return {project, notificationRule, ruleIds};
118+
} catch (error) {
119+
await rollbackProject(project);
120+
throw error;
121+
}
78122
},
79123
});
80124
}

static/app/views/projectInstall/createProject.spec.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,116 @@ describe('CreateProject', () => {
365365
expect(frameWorkModalMockRequests.projectCreationMockRequest).not.toHaveBeenCalled();
366366
});
367367

368+
it('should rollback project when rule creation fails', async () => {
369+
const {organization} = initializeOrg({
370+
organization: {
371+
access: ['project:read'],
372+
features: ['team-roles'],
373+
allowMemberProjectCreation: true,
374+
},
375+
});
376+
377+
const discordIntegration = OrganizationIntegrationsFixture({
378+
id: '338731',
379+
name: "Moo Deng's Server",
380+
provider: {
381+
key: 'discord',
382+
slug: 'discord',
383+
name: 'Discord',
384+
canAdd: true,
385+
canDisable: false,
386+
features: ['alert-rule', 'chat-unfurl'],
387+
aspects: {
388+
alerts: [],
389+
},
390+
},
391+
});
392+
393+
TeamStore.loadUserTeams([teamWithAccess]);
394+
395+
MockApiClient.addMockResponse({
396+
url: `/organizations/${organization.slug}/teams/`,
397+
body: [TeamFixture({slug: teamWithAccess.slug})],
398+
});
399+
400+
MockApiClient.addMockResponse({
401+
url: `/organizations/${organization.slug}/`,
402+
body: organization,
403+
});
404+
405+
MockApiClient.addMockResponse({
406+
url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`,
407+
body: [discordIntegration],
408+
});
409+
410+
MockApiClient.addMockResponse({
411+
url: `/organizations/${organization.slug}/integrations/${discordIntegration.id}/channels/`,
412+
body: {
413+
results: [
414+
{
415+
id: '1437461639900303454',
416+
name: 'general',
417+
display: '#general',
418+
type: 'text',
419+
},
420+
],
421+
},
422+
});
423+
424+
const projectCreationMockRequest = MockApiClient.addMockResponse({
425+
url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`,
426+
method: 'POST',
427+
body: {id: '1', slug: 'testProj', name: 'Test Project'},
428+
});
429+
430+
const ruleCreationMockRequest = MockApiClient.addMockResponse({
431+
url: `/projects/${organization.slug}/testProj/rules/`,
432+
method: 'POST',
433+
statusCode: 400,
434+
body: {
435+
actions: ['Discord: Discord channel URL is missing or formatted incorrectly'],
436+
},
437+
});
438+
439+
const projectDeletionMockRequest = MockApiClient.addMockResponse({
440+
url: `/projects/${organization.slug}/testProj/`,
441+
method: 'DELETE',
442+
});
443+
444+
MockApiClient.addMockResponse({
445+
url: `/organizations/${organization.slug}/projects/`,
446+
body: [
447+
{
448+
id: '1',
449+
slug: 'testProj',
450+
name: 'Test Project',
451+
},
452+
],
453+
});
454+
455+
render(<CreateProject />, {organization});
456+
457+
await userEvent.click(screen.getByTestId('platform-apple-ios'));
458+
await userEvent.click(
459+
screen.getByRole('checkbox', {
460+
name: /Notify via integration/,
461+
})
462+
);
463+
await selectEvent.select(screen.getByLabelText('channel'), /#general/);
464+
await userEvent.click(screen.getByRole('button', {name: 'Create Project'}));
465+
await waitFor(() => {
466+
expect(projectCreationMockRequest).toHaveBeenCalledTimes(1);
467+
});
468+
await waitFor(() => {
469+
expect(ruleCreationMockRequest).toHaveBeenCalled();
470+
});
471+
await waitFor(() => {
472+
expect(projectDeletionMockRequest).toHaveBeenCalledTimes(1);
473+
});
474+
475+
expect(addErrorMessage).toHaveBeenCalledWith('Failed to create project apple-ios');
476+
});
477+
368478
describe('Issue Alerts Options', () => {
369479
const organization = OrganizationFixture();
370480
beforeEach(() => {

static/app/views/projectInstall/createProject.tsx

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {PlatformIcon} from 'platformicons';
77

88
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
99
import {openConsoleModal, openModal} from 'sentry/actionCreators/modal';
10-
import {removeProject} from 'sentry/actionCreators/projects';
1110
import Access from 'sentry/components/acl/access';
1211
import {Button} from 'sentry/components/core/button';
1312
import {Input} from 'sentry/components/core/input';
@@ -35,7 +34,6 @@ import {decodeScalar} from 'sentry/utils/queryString';
3534
import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
3635
import slugify from 'sentry/utils/slugify';
3736
import normalizeUrl from 'sentry/utils/url/normalizeUrl';
38-
import useApi from 'sentry/utils/useApi';
3937
import {useCanCreateProject} from 'sentry/utils/useCanCreateProject';
4038
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
4139
import {useLocation} from 'sentry/utils/useLocation';
@@ -138,7 +136,6 @@ function getSubmitTooltipText({
138136

139137
export function CreateProject() {
140138
const globalModal = useGlobalModal();
141-
const api = useApi();
142139
const navigate = useNavigate();
143140
const organization = useOrganization();
144141
const location = useLocation();
@@ -268,8 +265,6 @@ export function CreateProject() {
268265
}) => {
269266
const selectedPlatform = selectedFramework ?? platform;
270267

271-
let projectToRollback: Project | undefined;
272-
273268
try {
274269
const {project, notificationRule, ruleIds} =
275270
await createProjectAndRules.mutateAsync({
@@ -279,7 +274,6 @@ export function CreateProject() {
279274
alertRuleConfig,
280275
createNotificationAction,
281276
});
282-
projectToRollback = project;
283277

284278
trackAnalytics('project_creation_page.created', {
285279
organization,
@@ -346,34 +340,12 @@ export function CreateProject() {
346340
Sentry.captureMessage('Project creation failed');
347341
});
348342
}
349-
350-
if (projectToRollback) {
351-
Sentry.logger.error('Rolling back project', {
352-
projectToRollback,
353-
});
354-
try {
355-
// Rolling back the project also deletes its associated alert rules
356-
// due to the cascading delete constraint.
357-
await removeProject({
358-
api,
359-
orgSlug: organization.slug,
360-
projectSlug: projectToRollback.slug,
361-
origin: 'getting_started',
362-
});
363-
} catch (err) {
364-
Sentry.withScope(scope => {
365-
scope.setExtra('error', err);
366-
Sentry.captureMessage('Failed to rollback project');
367-
});
368-
}
369-
}
370343
}
371344
},
372345
[
373346
organization,
374347
setCreatedProject,
375348
navigate,
376-
api,
377349
createProjectAndRules,
378350
createNotificationAction,
379351
alertRuleConfig,

0 commit comments

Comments
 (0)