Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 69 additions & 25 deletions static/app/components/onboarding/useCreateProjectAndRules.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -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<Response, RequestError, Variables>({
mutationKey: [MUTATION_KEY],
Expand All @@ -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;
}
},
});
}
Expand Down
110 changes: 110 additions & 0 deletions static/app/views/projectInstall/createProject.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CreateProject />, {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(() => {
Expand Down
28 changes: 0 additions & 28 deletions static/app/views/projectInstall/createProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -138,7 +136,6 @@ function getSubmitTooltipText({

export function CreateProject() {
const globalModal = useGlobalModal();
const api = useApi();
const navigate = useNavigate();
const organization = useOrganization();
const location = useLocation();
Expand Down Expand Up @@ -268,8 +265,6 @@ export function CreateProject() {
}) => {
const selectedPlatform = selectedFramework ?? platform;

let projectToRollback: Project | undefined;

try {
const {project, notificationRule, ruleIds} =
await createProjectAndRules.mutateAsync({
Expand All @@ -279,7 +274,6 @@ export function CreateProject() {
alertRuleConfig,
createNotificationAction,
});
projectToRollback = project;

trackAnalytics('project_creation_page.created', {
organization,
Expand Down Expand Up @@ -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,
Expand Down
Loading