diff --git a/static/app/components/onboarding/useCreateProjectAndRules.ts b/static/app/components/onboarding/useCreateProjectAndRules.ts index e1dc07f00fec7c..452b326a96c8b6 100644 --- a/static/app/components/onboarding/useCreateProjectAndRules.ts +++ b/static/app/components/onboarding/useCreateProjectAndRules.ts @@ -1,3 +1,7 @@ +import {useCallback} from 'react'; +import * as Sentry from '@sentry/react'; + +import {removeProject} from 'sentry/actionCreators/projects'; import {useCreateProject} from 'sentry/components/onboarding/useCreateProject'; import {useCreateProjectRules} from 'sentry/components/onboarding/useCreateProjectRules'; import type {IssueAlertRule} from 'sentry/types/alerts'; @@ -6,6 +10,8 @@ import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import {useIsMutating, useMutation, useMutationState} from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; import type {useCreateNotificationAction} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; import type {RequestDataFragment} from 'sentry/views/projectInstall/issueAlertOptions'; @@ -27,9 +33,40 @@ type Response = { notificationRule?: IssueAlertRule; }; +function useRollbackProject() { + const api = useApi(); + const organization = useOrganization(); + + return useCallback( + async (project: Project) => { + Sentry.logger.error('Rolling back project', { + projectToRollback: project, + }); + + try { + // Rolling back the project also deletes its associated alert rules + // due to the cascading delete constraint. + await removeProject({ + api, + orgSlug: organization.slug, + projectSlug: project.slug, + origin: 'getting_started', + }); + } catch (err) { + Sentry.withScope(scope => { + scope.setExtra('error', err); + Sentry.captureMessage('Failed to rollback project'); + }); + } + }, + [api, organization.slug] + ); +} + export function useCreateProjectAndRules() { const createProject = useCreateProject(); const createProjectRules = useCreateProjectRules(); + const rollbackProject = useRollbackProject(); return useMutation({ mutationKey: [MUTATION_KEY], @@ -47,34 +84,41 @@ export function useCreateProjectAndRules() { firstTeamSlug: team, }); - const customRulePromise = alertRuleConfig?.shouldCreateCustomRule - ? createProjectRules.mutateAsync({ - projectSlug: project.slug, - name: project.name, - conditions: alertRuleConfig?.conditions, - actions: alertRuleConfig?.actions, - actionMatch: alertRuleConfig?.actionMatch, - frequency: alertRuleConfig?.frequency, - }) - : undefined; - - const notificationRulePromise = createNotificationAction({ - shouldCreateRule: alertRuleConfig?.shouldCreateRule, - name: project.name, - projectSlug: project.slug, - conditions: alertRuleConfig?.conditions, - actionMatch: alertRuleConfig?.actionMatch, - frequency: alertRuleConfig?.frequency, - }); + try { + const customRulePromise = alertRuleConfig?.shouldCreateCustomRule + ? createProjectRules.mutateAsync({ + projectSlug: project.slug, + name: project.name, + conditions: alertRuleConfig?.conditions, + actions: alertRuleConfig?.actions, + actionMatch: alertRuleConfig?.actionMatch, + frequency: alertRuleConfig?.frequency, + }) + : undefined; + + const notificationRulePromise = createNotificationAction({ + shouldCreateRule: alertRuleConfig?.shouldCreateRule, + name: project.name, + projectSlug: project.slug, + conditions: alertRuleConfig?.conditions, + actionMatch: alertRuleConfig?.actionMatch, + frequency: alertRuleConfig?.frequency, + }); - const [customRule, notificationRule] = await Promise.all([ - customRulePromise, - notificationRulePromise, - ]); + const [customRule, notificationRule] = await Promise.all([ + customRulePromise, + notificationRulePromise, + ]); - const ruleIds = [customRule, notificationRule].filter(defined).map(rule => rule.id); + const ruleIds = [customRule, notificationRule] + .filter(defined) + .map(rule => rule.id); - return {project, notificationRule, ruleIds}; + return {project, notificationRule, ruleIds}; + } catch (error) { + await rollbackProject(project); + throw error; + } }, }); } diff --git a/static/app/views/projectInstall/createProject.spec.tsx b/static/app/views/projectInstall/createProject.spec.tsx index 2ddbb7e9ed8540..280245310a92a8 100644 --- a/static/app/views/projectInstall/createProject.spec.tsx +++ b/static/app/views/projectInstall/createProject.spec.tsx @@ -365,6 +365,116 @@ describe('CreateProject', () => { expect(frameWorkModalMockRequests.projectCreationMockRequest).not.toHaveBeenCalled(); }); + it('should rollback project when rule creation fails', async () => { + const {organization} = initializeOrg({ + organization: { + access: ['project:read'], + features: ['team-roles'], + allowMemberProjectCreation: true, + }, + }); + + const discordIntegration = OrganizationIntegrationsFixture({ + id: '338731', + name: "Moo Deng's Server", + provider: { + key: 'discord', + slug: 'discord', + name: 'Discord', + canAdd: true, + canDisable: false, + features: ['alert-rule', 'chat-unfurl'], + aspects: { + alerts: [], + }, + }, + }); + + TeamStore.loadUserTeams([teamWithAccess]); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/teams/`, + body: [TeamFixture({slug: teamWithAccess.slug})], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + body: organization, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`, + body: [discordIntegration], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${discordIntegration.id}/channels/`, + body: { + results: [ + { + id: '1437461639900303454', + name: 'general', + display: '#general', + type: 'text', + }, + ], + }, + }); + + const projectCreationMockRequest = MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, + method: 'POST', + body: {id: '1', slug: 'testProj', name: 'Test Project'}, + }); + + const ruleCreationMockRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/testProj/rules/`, + method: 'POST', + statusCode: 400, + body: { + actions: ['Discord: Discord channel URL is missing or formatted incorrectly'], + }, + }); + + const projectDeletionMockRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/testProj/`, + method: 'DELETE', + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [ + { + id: '1', + slug: 'testProj', + name: 'Test Project', + }, + ], + }); + + render(, {organization}); + + await userEvent.click(screen.getByTestId('platform-apple-ios')); + await userEvent.click( + screen.getByRole('checkbox', { + name: /Notify via integration/, + }) + ); + await selectEvent.select(screen.getByLabelText('channel'), /#general/); + await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); + await waitFor(() => { + expect(projectCreationMockRequest).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(ruleCreationMockRequest).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(projectDeletionMockRequest).toHaveBeenCalledTimes(1); + }); + + expect(addErrorMessage).toHaveBeenCalledWith('Failed to create project apple-ios'); + }); + describe('Issue Alerts Options', () => { const organization = OrganizationFixture(); beforeEach(() => { diff --git a/static/app/views/projectInstall/createProject.tsx b/static/app/views/projectInstall/createProject.tsx index e72bc4664c4331..97efe751841b5d 100644 --- a/static/app/views/projectInstall/createProject.tsx +++ b/static/app/views/projectInstall/createProject.tsx @@ -7,7 +7,6 @@ import {PlatformIcon} from 'platformicons'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openConsoleModal, openModal} from 'sentry/actionCreators/modal'; -import {removeProject} from 'sentry/actionCreators/projects'; import Access from 'sentry/components/acl/access'; import {Button} from 'sentry/components/core/button'; import {Input} from 'sentry/components/core/input'; @@ -35,7 +34,6 @@ import {decodeScalar} from 'sentry/utils/queryString'; import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import slugify from 'sentry/utils/slugify'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; -import useApi from 'sentry/utils/useApi'; import {useCanCreateProject} from 'sentry/utils/useCanCreateProject'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; @@ -138,7 +136,6 @@ function getSubmitTooltipText({ export function CreateProject() { const globalModal = useGlobalModal(); - const api = useApi(); const navigate = useNavigate(); const organization = useOrganization(); const location = useLocation(); @@ -268,8 +265,6 @@ export function CreateProject() { }) => { const selectedPlatform = selectedFramework ?? platform; - let projectToRollback: Project | undefined; - try { const {project, notificationRule, ruleIds} = await createProjectAndRules.mutateAsync({ @@ -279,7 +274,6 @@ export function CreateProject() { alertRuleConfig, createNotificationAction, }); - projectToRollback = project; trackAnalytics('project_creation_page.created', { organization, @@ -346,34 +340,12 @@ export function CreateProject() { Sentry.captureMessage('Project creation failed'); }); } - - if (projectToRollback) { - Sentry.logger.error('Rolling back project', { - projectToRollback, - }); - try { - // Rolling back the project also deletes its associated alert rules - // due to the cascading delete constraint. - await removeProject({ - api, - orgSlug: organization.slug, - projectSlug: projectToRollback.slug, - origin: 'getting_started', - }); - } catch (err) { - Sentry.withScope(scope => { - scope.setExtra('error', err); - Sentry.captureMessage('Failed to rollback project'); - }); - } - } } }, [ organization, setCreatedProject, navigate, - api, createProjectAndRules, createNotificationAction, alertRuleConfig,