Skip to content

Commit

Permalink
Merge branch 'main' into darkmode-conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
PatrickAtlassian committed Dec 6, 2023
2 parents 58b7977 + b550727 commit f1a0b89
Show file tree
Hide file tree
Showing 14 changed files with 4,464 additions and 1,289 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
16.15.1
18.16.1
6 changes: 6 additions & 0 deletions manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ modules:
method: import
app:
id: ari:cloud:ecosystem::app/fe7b0913-7421-4c84-b401-041eaab2ef2e
features:
autoUserConsent: true
resources:
- key: main
path: ui/build
Expand All @@ -81,6 +83,10 @@ permissions:
- read:metric:compass
external:
fetch:
client:
- '*.gitlab.com'
- 'gitlab.com'
- '*.atlassian.com'
backend:
- '*.services.atlassian.com'
- 'https://gitlab.com'
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"private": true,
"devDependencies": {
"@atlaskit/eslint-plugin-design-system": "^8.7.0",
"@forge/cli": "^4.3.2",
"@forge/cli": "^6.21.0",
"@types/jest": "^27.4.1",
"@types/js-yaml": "^4.0.5",
"@types/lodash": "^4.14.182",
Expand Down Expand Up @@ -36,7 +36,7 @@
},
"dependencies": {
"@atlaskit/tokens": "^1.14.0",
"@atlassian/forge-graphql": "13.3.0",
"@atlassian/forge-graphql": "13.3.10",
"@forge/api": "^2.8.1",
"@forge/bridge": "^2.6.0",
"@forge/events": "^0.5.3",
Expand Down
11 changes: 11 additions & 0 deletions src/entry/webtriggers/process-gitlab-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ describe('processGitlabEvent', () => {
expect(serverResponse).toHaveBeenCalledWith('Invalid event format', 400);
});

it('returns server response error in case of unexpected error', async () => {
const webtriggerRequest = generateWebtriggerRequest('<p>Invalid body</p>');

storage.getSecret.mockRejectedValue(new Error());

await processGitlabEvent(webtriggerRequest, MOCK_CONTEXT);

expect(mockHandlePushEvent).not.toHaveBeenCalled();
expect(serverResponse).toHaveBeenCalledWith('The webhook could not be processed', 500);
});

it('handles pipeline event when FF is enabled', async () => {
const webtriggerRequest = generateWebtriggerRequest(JSON.stringify(MOCK_PIPELINE_EVENT));

Expand Down
70 changes: 41 additions & 29 deletions src/entry/webtriggers/process-gitlab-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,59 +19,71 @@ import {
handlePipelineEvent,
} from './gitlab-event-handlers';
import { listFeatures } from '../../services/feature-flags';
import { ParseWebhookEventPayloadError, ValidateWebhookSignatureError } from '../../models/errors';

type Context = {
principal: undefined;
installContext: string;
};

class ValidateWebhookSignatureError extends Error {}

const validateWebhookSignature = (eventSignature: string, controlSignature: string): void | never => {
if (eventSignature !== controlSignature) {
throw new ValidateWebhookSignatureError();
}
};

export const processGitlabEvent = async (event: WebtriggerRequest, context: Context): Promise<WebtriggerResponse> => {
const { installContext } = context;
const cloudId = parse(installContext).resourceId;
const groupId = event.queryParameters.groupId[0];
const groupToken = await storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`);
const eventPayload = event.body;
let parsedEvent: GitlabEvent;
const parseEventPayload = (eventPayload: string): GitlabEvent | never => {
try {
return JSON.parse(eventPayload);
} catch {
throw new ParseWebhookEventPayloadError();
}
};

export const processGitlabEvent = async (event: WebtriggerRequest, context: Context): Promise<WebtriggerResponse> => {
try {
const { installContext } = context;
const cloudId = parse(installContext).resourceId;
const groupId = event.queryParameters.groupId[0];
const groupToken = await storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`);
const eventPayload = event.body;

validateWebhookSignature(
event.headers['x-gitlab-token'][0],
await storage.get(`${STORAGE_KEYS.WEBHOOK_SIGNATURE_PREFIX}${groupId}`),
);
parsedEvent = JSON.parse(eventPayload);

const parsedEvent = parseEventPayload(eventPayload);

if (parsedEvent.object_kind === 'push') {
await handlePushEvent(parsedEvent as PushEvent, groupToken, cloudId);
}

if (parsedEvent.object_kind === 'merge_request') {
await handleMergeRequestEvent(parsedEvent as MergeRequestEvent, groupToken, cloudId);
}

if (parsedEvent.object_kind === 'pipeline') {
await handlePipelineEvent(parsedEvent as PipelineEvent, groupToken, cloudId);
}

if (parsedEvent.object_kind === 'deployment') {
await handleDeploymentEvent(parsedEvent as DeploymentEvent, groupToken, cloudId);
}

return serverResponse('Processed webhook event');
} catch (error) {
if (error instanceof ValidateWebhookSignatureError) {
console.error({ message: 'Webhook event secret is invalid', error });
return serverResponse('Invalid webhook secret', 403);
}

console.error({ message: 'Failed parsing webhook event', error });
return serverResponse('Invalid event format', 400);
}

if (parsedEvent.object_kind === 'push') {
await handlePushEvent(parsedEvent as PushEvent, groupToken, cloudId);
}

if (parsedEvent.object_kind === 'merge_request') {
await handleMergeRequestEvent(parsedEvent as MergeRequestEvent, groupToken, cloudId);
}

if (parsedEvent.object_kind === 'pipeline') {
await handlePipelineEvent(parsedEvent as PipelineEvent, groupToken, cloudId);
}
if (error instanceof ParseWebhookEventPayloadError) {
console.error({ message: 'Failed parsing webhook event', error });
return serverResponse('Invalid event format', 400);
}

if (parsedEvent.object_kind === 'deployment') {
await handleDeploymentEvent(parsedEvent as DeploymentEvent, groupToken, cloudId);
console.error({ message: 'Unexpected error while processing webhook', error });
return serverResponse('The webhook could not be processed', 500);
}

return serverResponse('Processed webhook event');
};
4 changes: 4 additions & 0 deletions src/models/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,7 @@ export class GitlabHttpMethodError extends Error {
this.statusText = statusText;
}
}

export class ValidateWebhookSignatureError extends Error {}

export class ParseWebhookEventPayloadError extends Error {}
60 changes: 34 additions & 26 deletions src/services/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,41 @@ import { GITLAB_EVENT_WEBTRIGGER, STORAGE_KEYS, STORAGE_SECRETS } from '../const
import { generateSignature } from '../utils/generate-signature-utils';

export const setupAndValidateWebhook = async (groupId: number): Promise<number> => {
const [existingWebhook, groupToken] = await Promise.all([
storage.get(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`),
storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`),
]);

const isWebhookValid = existingWebhook && (await getGroupWebhook(groupId, existingWebhook, groupToken)) !== null;

if (isWebhookValid) {
return existingWebhook;
console.log('Setting up webhook');
try {
const [existingWebhook, groupToken] = await Promise.all([
storage.get(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`),
storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`),
]);

const isWebhookValid = existingWebhook && (await getGroupWebhook(groupId, existingWebhook, groupToken)) !== null;

if (isWebhookValid) {
console.log('Using existing webhook');
return existingWebhook;
}

const webtriggerURL = await webTrigger.getUrl(GITLAB_EVENT_WEBTRIGGER);
const webtriggerURLWithGroupId = `${webtriggerURL}?groupId=${groupId}`;
const webhookSignature = generateSignature();
const webhookId = await registerGroupWebhook({
groupId,
url: webtriggerURLWithGroupId,
token: groupToken,
signature: webhookSignature,
});

await Promise.all([
storage.set(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`, webhookId),
storage.set(`${STORAGE_KEYS.WEBHOOK_SIGNATURE_PREFIX}${groupId}`, webhookSignature),
]);

console.log('Successfully created webhook');
return webhookId;
} catch (e) {
console.log('Error setting up webhook, ', e);
return null;
}

const webtriggerURL = await webTrigger.getUrl(GITLAB_EVENT_WEBTRIGGER);
const webtriggerURLWithGroupId = `${webtriggerURL}?groupId=${groupId}`;
const webhookSignature = generateSignature();
const webhookId = await registerGroupWebhook({
groupId,
url: webtriggerURLWithGroupId,
token: groupToken,
signature: webhookSignature,
});

await Promise.all([
storage.set(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`, webhookId),
storage.set(`${STORAGE_KEYS.WEBHOOK_SIGNATURE_PREFIX}${groupId}`, webhookSignature),
]);

return webhookId;
};

export const deleteWebhook = async (groupId: number): Promise<void> => {
Expand Down
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@atlaskit/theme": "^12.1.6",
"@atlaskit/tokens": "^1.14.0",
"@atlaskit/tooltip": "^17.5.9",
"@atlassian/forge-graphql": "13.0.1",
"@atlassian/forge-graphql": "13.3.10",
"@forge/api": "^2.8.1",
"@forge/bridge": "^2.6.0",
"escape-string-regexp": "^5.0.0",
Expand Down
62 changes: 42 additions & 20 deletions ui/src/components/SelectImportPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { router } from '@forge/bridge';

Expand All @@ -14,6 +14,7 @@ import { useAppContext } from '../../hooks/useAppContext';
import { useComponentTypes } from '../../hooks/useComponentTypes';
import { getComponentTypeOption } from '../utils';
import { getAvailableImportComponentTypes } from './utils';
import { useProjects } from '../../hooks/useProjects';

export enum Screens {
CONFIRMATION = 'CONFIRMATION',
Expand Down Expand Up @@ -47,6 +48,10 @@ export const SelectImportPage = () => {
const [groups, setGroups] = useState<GitlabAPIGroup[]>([]);
const [search, setSearch] = useState<string>();

const { changedProjects, setChangedProjects } = useProjects(projects);

const selectedProjects = changedProjects.filter((item) => item.isSelected);

const fetchGroups = async () => {
setIsGroupsLoading(true);

Expand Down Expand Up @@ -74,13 +79,14 @@ export const SelectImportPage = () => {
getGroupProjects(groupId, page, locationGroupId, search)
.then(({ data, success, errors }) => {
if (success && data && data.projects.length) {
const projectsForTable = data.projects.map((project) => ({
...project,
isSelected: false,
shouldOpenMR: false,
typeOption: getComponentTypeOption(project?.typeId),
}));

const projectsForTable = data.projects.map((project) => {
const selectedProject = changedProjects.find((selectedRepo) => selectedRepo.id === project.id);
return {
...project,
isSelected: Boolean(selectedProject?.isSelected),
typeOption: selectedProject?.typeOption ?? getComponentTypeOption(project?.typeId),
};
});
setTotalProjects(data.total);
setProjects((prevState) => [...prevState, ...projectsForTable]);
}
Expand Down Expand Up @@ -150,13 +156,11 @@ export const SelectImportPage = () => {

const onChangeComponentType = (id: number, componentTypeOption: CompassComponentTypeOption) => {
setProjects((prevProjects) =>
prevProjects.map((project) => {
if (id === project.id) {
return { ...project, typeOption: componentTypeOption };
}
prevProjects.map((project) => (id === project.id ? { ...project, typeOption: componentTypeOption } : project)),
);

return project;
}),
setChangedProjects((prevState) =>
prevState.map((project) => (id === project.id ? { ...project, typeOption: componentTypeOption } : project)),
);
};

Expand All @@ -166,13 +170,33 @@ export const SelectImportPage = () => {
setIsProjectsLoading(true);
};

const handleChangeGroup = (item: SelectorItem | null) => {
const handleClearSelectedGroup = () => {
const isSelectionClearedOnEmptyState = groupId === locationGroupId;

if (isSelectionClearedOnEmptyState) {
return;
}

resetInitialProjectsData();
setGroupId(locationGroupId);
};

const handleSelectGroup = (item: SelectorItem) => {
const isSameGroupSelected = item.value === groupId;

if (isSameGroupSelected) {
return;
}

resetInitialProjectsData();
setGroupId(item.value);
};

const handleChangeGroup = (item: SelectorItem | null) => {
if (item) {
setGroupId(item.value);
handleSelectGroup(item);
} else {
setGroupId(locationGroupId);
handleClearSelectedGroup();
}
};

Expand All @@ -182,8 +206,6 @@ export const SelectImportPage = () => {
setSearch(value);
};

const selectedProjects = useMemo(() => projects.filter(({ isSelected }) => isSelected), [projects]);

const handleNavigateToConnectedPage = () => {
router.navigate('/compass/components');
};
Expand All @@ -203,7 +225,7 @@ export const SelectImportPage = () => {
const handleImportProjects = () => {
setIsProjectsImporting(true);

const projectsReadyToImport = projects.reduce<ImportableProject[]>((acc, curr) => {
const projectsReadyToImport = selectedProjects.reduce<ImportableProject[]>((acc, curr) => {
if (curr.isSelected) {
acc.push({
...curr,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export const SelectProjectsScreen = ({
locationGroupId,
importableComponentTypes,
}: Props) => {
const groupSelectorOptions = useMemo(() => buildGroupsSelectorOptions(groups, locationGroupId), [groups]);
const groupSelectorOptions = useMemo(
() => buildGroupsSelectorOptions(groups, locationGroupId),
[groups, locationGroupId],
);

return (
<Wrapper data-testid='gitlab-select-projects-screen'>
Expand All @@ -71,7 +74,7 @@ export const SelectProjectsScreen = ({
</OverrideDescription>
<>
<TableHeaderWrapper>
<GroupSelectorWrapper>
<GroupSelectorWrapper data-testid='group-selector'>
<Select
isClearable
isLoading={isGroupsLoading}
Expand Down
Loading

0 comments on commit f1a0b89

Please sign in to comment.