Skip to content

Commit

Permalink
Merge pull request #136 from atlassian-labs/isuse/COMPASS-23317-ui
Browse files Browse the repository at this point in the history
Enable basic Maintainer token role UI inputs
  • Loading branch information
subbuvenk-atlas authored Dec 17, 2024
2 parents 55520d5 + 0dd1e2f commit e130e9d
Show file tree
Hide file tree
Showing 21 changed files with 925 additions and 111 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ module.exports = {
},
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-empty-function': 'off',
},
},
],
Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const STORAGE_KEYS = {
WEBHOOK_KEY_PREFIX: 'webhook-id-',
TOKEN_ROLE_PREFIX: 'tokenRole-',
WEBHOOK_SIGNATURE_PREFIX: 'webhook-sign-id-',
WEBHOOK_SETUP_IN_PROGRESS: 'webhook-setup-in-progress-',
LAST_SYNC_TIME: 'lastSyncTime',
CURRENT_IMPORT_TOTAL_PROJECTS: 'currentImportTotalProjects',
CURRENT_IMPORT_QUEUE_JOB_IDS: 'currentImportQueueJobIds',
Expand All @@ -18,6 +19,9 @@ export const STORAGE_KEYS = {

export const STORAGE_SECRETS = {
GROUP_TOKEN_KEY_PREFIX: 'groupToken-',
WEBHOOK_SECRET_TOKEN_KEY_PREFIX: 'webhookSecretToken-',
TOKEN_ROLE_PREFIX: 'tokenRole-',
GROUP_NAME_PREFIX: 'groupName-',
};

export const REQUIRED_SCOPES = ['api', 'write_repository'];
Expand Down
48 changes: 42 additions & 6 deletions src/resolvers/admin-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { setupAndValidateWebhook } from '../services/webhooks';
import { disconnectGroup } from '../services/disconnect-group';
import { getForgeAppId } from '../utils/get-forge-app-id';
import { getLastSyncTime } from '../services/last-sync-time';
import { appId, connectedGroupsInfo, getFeatures, groupsAllExisting } from './shared-resolvers';
import { ConnectGroupInput } from '../types';
import { appId, connectedGroupsInfo, getFeatures, groupsAllExisting, webhookSetupConfig } from './shared-resolvers';
import { ConnectGroupInput, GitLabRoles, WebhookSetupConfig } from '../types';

const resolver = new Resolver();

Expand All @@ -35,9 +35,13 @@ resolver.define('groups/connectedInfo', async (): Promise<ResolverResponse<Gitla
return connectedGroupsInfo();
});

resolver.define('webhooks/setupConfig', async (): Promise<ResolverResponse<WebhookSetupConfig>> => {
return webhookSetupConfig();
});

resolver.define('groups/connect', async (req): Promise<ResolverResponse> => {
const {
payload: { groupToken, groupTokenName, groupRole, groupName, webhookId, webhookSecretToken },
payload: { groupToken, groupTokenName, groupRole, groupName },
context: { cloudId },
} = req;
try {
Expand All @@ -46,12 +50,15 @@ resolver.define('groups/connect', async (req): Promise<ResolverResponse> => {
tokenName: groupTokenName,
tokenRole: groupRole,
groupName,
webhookId,
webhookSecretToken,
};
const groupId = await connectGroup(input);

await setupAndValidateWebhook(groupId, webhookId, webhookSecretToken);
const skipWebhookSetup = groupRole === GitLabRoles.MAINTAINER;
if (skipWebhookSetup) {
return { success: true };
}

await setupAndValidateWebhook(groupId);

await graphqlGateway.compass.asApp().synchronizeLinkAssociations({
cloudId,
Expand All @@ -74,6 +81,35 @@ resolver.define('groups/connect', async (req): Promise<ResolverResponse> => {
}
});

resolver.define('webhooks/connectInProgress', async (req): Promise<ResolverResponse> => {
const {
payload: { groupId, webhookId, webhookSecretToken },
context: { cloudId },
} = req;
try {
if (!groupId) {
return {
success: false,
errors: [{ message: 'No webhook setup in progress.', errorType: AuthErrorTypes.UNEXPECTED_ERROR }],
};
}

await setupAndValidateWebhook(groupId, webhookId, webhookSecretToken);

await graphqlGateway.compass.asApp().synchronizeLinkAssociations({
cloudId,
forgeAppId: getForgeAppId(),
});

return { success: true };
} catch (e) {
return {
success: false,
errors: [{ message: e.message, errorType: AuthErrorTypes.UNEXPECTED_ERROR }],
};
}
});

resolver.define('groups/allExisting', async (): Promise<ResolverResponse<GitlabAPIGroup[]>> => {
return groupsAllExisting();
});
Expand Down
8 changes: 6 additions & 2 deletions src/resolvers/import-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import {
ImportFailedError,
importProjects,
} from '../services/import-projects';
import { GroupProjectsResponse, TeamsWithMembershipStatus } from '../types';
import { GroupProjectsResponse, TeamsWithMembershipStatus, WebhookSetupConfig } from '../types';
import { getAllComponentTypeIds } from '../client/compass';
import { appId, connectedGroupsInfo, getFeatures, groupsAllExisting } from './shared-resolvers';
import { appId, connectedGroupsInfo, getFeatures, groupsAllExisting, webhookSetupConfig } from './shared-resolvers';
import { getFirstPageOfTeamsWithMembershipStatus } from '../services/get-teams';
import { getTeamOnboarding, setTeamOnboarding } from '../services/onboarding';

Expand Down Expand Up @@ -129,6 +129,10 @@ resolver.define('appId', (): ResolverResponse<string> => {
return appId();
});

resolver.define('webhooks/setupConfig', async (): Promise<ResolverResponse<WebhookSetupConfig>> => {
return webhookSetupConfig();
});

resolver.define('getAllCompassComponentTypes', async (req): Promise<ResolverResponse<CompassComponentTypeObject[]>> => {
const { cloudId } = req.context;
try {
Expand Down
21 changes: 19 additions & 2 deletions src/resolvers/shared-resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AuthErrorTypes, DefaultErrorTypes, FeaturesList, GitlabAPIGroup, ResolverResponse } from '../resolverTypes';
import { listFeatures } from '../services/feature-flags';
import { getAllExistingGroups, getConnectedGroups } from '../services/group';
import { setupAndValidateWebhook } from '../services/webhooks';
import { getWebhookSetupConfig, setupAndValidateWebhook } from '../services/webhooks';
import { getForgeAppId } from '../utils/get-forge-app-id';
import { WebhookSetupConfig } from '../types';

export const getFeatures = (): ResolverResponse<FeaturesList> => {
try {
Expand Down Expand Up @@ -35,8 +36,9 @@ export const groupsAllExisting = async (): Promise<ResolverResponse<GitlabAPIGro
export const connectedGroupsInfo = async (): Promise<ResolverResponse<GitlabAPIGroup[]>> => {
try {
const connectedGroups = await getConnectedGroups();
const setupConfig = await getWebhookSetupConfig();

if (connectedGroups.length) {
if (connectedGroups.length && !setupConfig.webhookSetupInProgress) {
await setupAndValidateWebhook(connectedGroups[0].id);
}

Expand All @@ -63,3 +65,18 @@ export const appId = (): ResolverResponse<string> => {
};
}
};

export const webhookSetupConfig = async (): Promise<ResolverResponse<WebhookSetupConfig>> => {
try {
const config = await getWebhookSetupConfig();
return {
success: true,
data: config,
};
} catch (e) {
return {
success: false,
errors: [{ message: e.message, errorType: DefaultErrorTypes.UNEXPECTED_ERROR }],
};
}
};
1 change: 1 addition & 0 deletions src/services/clear-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const clearStorageEntriesForGroup = async (groupId: string): Promise<void> => {
`${STORAGE_KEYS.TOKEN_ROLE_PREFIX}${groupId}`,
`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`,
`${STORAGE_KEYS.WEBHOOK_SIGNATURE_PREFIX}${groupId}`,
`${STORAGE_KEYS.WEBHOOK_SETUP_IN_PROGRESS}${groupId}`,
];

await deleteKeysFromStorageByChunks(groupKeys, CLEAR_STORAGE_CHUNK_SIZE, CLEAR_STORAGE_DELAY);
Expand Down
7 changes: 6 additions & 1 deletion src/services/group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,18 @@ describe('Group service', () => {
`${STORAGE_KEYS.TOKEN_ROLE_PREFIX}${MOCK_ANOTHER_GROUP_DATA.id}`,
GitLabRoles.MAINTAINER,
);
expect(storage.set).toHaveBeenNthCalledWith(
3,
`${STORAGE_KEYS.WEBHOOK_SETUP_IN_PROGRESS}${MOCK_ANOTHER_GROUP_DATA.id}`,
MOCK_ANOTHER_GROUP_DATA.id,
);
expect(storage.setSecret).toHaveBeenCalledWith(
`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${MOCK_ANOTHER_GROUP_DATA.id}`,
MOCK_TOKEN,
);

// Verify total number of calls
expect(storage.set).toHaveBeenCalledTimes(2);
expect(storage.set).toHaveBeenCalledTimes(3);
expect(storage.setSecret).toHaveBeenCalledTimes(1);

expect(result).toBe(MOCK_ANOTHER_GROUP_DATA.id);
Expand Down
4 changes: 4 additions & 0 deletions src/services/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export const connectGroup = async (input: ConnectGroupInput): Promise<number> =>
await storage.setSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`, token);
await storage.set(`${STORAGE_KEYS.TOKEN_ROLE_PREFIX}${groupId}`, tokenRole);

if (tokenRole === GitLabRoles.MAINTAINER) {
await storage.set(`${STORAGE_KEYS.WEBHOOK_SETUP_IN_PROGRESS}${groupId}`, groupId);
}

return groupId;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const mockDeleteGroupWebhook = mocked(deleteGroupWebhook);
const MOCK_GROUP_ID = 123;
const MOCK_WEBHOOK_KEY = `webhook-id-${MOCK_GROUP_ID}`;
const MOCK_WEBHOOK_SIGNATURE_KEY = `webhook-sign-id-${MOCK_GROUP_ID}`;
const MOCK_WEBHOOK_SETUP_IN_PROGRESS_KEY = `webhook-setup-in-progress-${MOCK_GROUP_ID}`;
const MOCK_WEBHOOK_ID = 345;

describe('setup webhook', () => {
Expand Down Expand Up @@ -104,6 +105,7 @@ describe('setup webhook', () => {
expect(mockRegisterGroupWebhook).not.toHaveBeenCalled();
expect(storage.set).toHaveBeenNthCalledWith(1, MOCK_WEBHOOK_KEY, MOCK_WEBHOOK_ID);
expect(storage.set).toHaveBeenNthCalledWith(2, MOCK_WEBHOOK_SIGNATURE_KEY, expect.anything());
expect(storage.delete).toHaveBeenNthCalledWith(1, MOCK_WEBHOOK_SETUP_IN_PROGRESS_KEY);
expect(result).toBe(MOCK_WEBHOOK_ID);
});
});
Expand Down
43 changes: 39 additions & 4 deletions src/services/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { storage, webTrigger } from '@forge/api';
import { Result, startsWith, storage, webTrigger } from '@forge/api';

import { registerGroupWebhook, deleteGroupWebhook, getGroupWebhook } from '../client/gitlab';
import { GITLAB_EVENT_WEBTRIGGER, STORAGE_KEYS, STORAGE_SECRETS } from '../constants';
import { generateSignature } from '../utils/generate-signature-utils';
import { ALL_SETTLED_STATUS, getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers';
import { GitLabRoles } from '../types';
import { WebhookSetupConfig, GitLabRoles } from '../types';

const getFormattedWebTriggerUrl = async (groupId: number): Promise<string> => {
const webtriggerURL = await webTrigger.getUrl(GITLAB_EVENT_WEBTRIGGER);
return `${webtriggerURL}?groupId=${groupId}`;
};

const setupAndValidateForOwnerToken = async (
groupId: number,
Expand All @@ -20,8 +25,7 @@ const setupAndValidateForOwnerToken = async (
return existingWebhook;
}

const webtriggerURL = await webTrigger.getUrl(GITLAB_EVENT_WEBTRIGGER);
const webtriggerURLWithGroupId = `${webtriggerURL}?groupId=${groupId}`;
const webtriggerURLWithGroupId = await getFormattedWebTriggerUrl(groupId);
const webhookSignature = generateSignature();
const webhookId = await registerGroupWebhook({
groupId,
Expand Down Expand Up @@ -65,6 +69,9 @@ const setupAndValidateForMaintainerToken = async (
throw new Error(`Error setting webhookId or webhookSignature: ${getFormattedErrors(settledResult)}`);
}

// Mark in-progress webhook setup as completed.
await storage.delete(`${STORAGE_KEYS.WEBHOOK_SETUP_IN_PROGRESS}${groupId}`);

console.log('Successfully created webhook with maintainer token role');
return webhookId;
};
Expand Down Expand Up @@ -152,3 +159,31 @@ export const deleteWebhook = async (groupId: number): Promise<void> => {
throw new Error(`Error deleting webhook: ${e}`);
}
};

/**
* Get webhook configuration details if this step is necessary for setup.
* Currently, this is applicable only for Maintainer token role as it requires manual setup.
*
* @returns {Promise<WebhookSetupConfig>} Webhook configuration details for first group in the in-progress list.
*/
export const getWebhookSetupConfig = async (): Promise<WebhookSetupConfig> => {
const result = storage.query().where('key', startsWith(STORAGE_KEYS.WEBHOOK_SETUP_IN_PROGRESS));

const { results: groups } = await result.getMany();
const groupsResult = await Promise.allSettled(groups.map((group: Result) => storage.get(group.key)));

if (hasRejections(groupsResult)) {
throw new Error(`Error getting groupIds with in-progress webhooks setup: ${getFormattedErrors(groupsResult)}`);
}

const groupIds = groupsResult.map((groupResult: PromiseFulfilledResult<number>) => groupResult.value);

const webhookSetupInProgress = groupIds.length > 0;
const triggerUrl = webhookSetupInProgress ? await getFormattedWebTriggerUrl(groupIds[0]) : '';

return {
webhookSetupInProgress,
triggerUrl,
groupId: webhookSetupInProgress ? groupIds[0] : null,
};
};
13 changes: 9 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,24 +427,28 @@ type BackfillData = {
};

enum GitLabRoles {
OWNER = 'owner',
MAINTAINER = 'maintainer',
OWNER = 'Owner',
MAINTAINER = 'Maintainer',
}

type ConnectGroupInput = {
token: string;
tokenName: string;
tokenRole: GitLabRoles;
groupName?: string;
webhookId?: string;
webhookSecretToken?: string;
};

type TokenFetchResult = {
token: string;
groupId: number;
};

type WebhookSetupConfig = {
webhookSetupInProgress: boolean;
triggerUrl: string;
groupId?: number;
};

export type {
WebtriggerRequest,
WebtriggerResponse,
Expand Down Expand Up @@ -488,6 +492,7 @@ export type {
CompareProjectWithExistingComponent,
BackfillData,
ConnectGroupInput,
WebhookSetupConfig,
TokenFetchResult,
};

Expand Down
3 changes: 2 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
"@atlaskit/dynamic-table": "^14.5.4",
"@atlaskit/empty-state": "^7.3.10",
"@atlaskit/form": "^8.5.4",
"@atlaskit/icon": "^21.10.6",
"@atlaskit/icon": "^23.1.0",
"@atlaskit/inline-message": "^11.4.9",
"@atlaskit/onboarding": "^11.3.0",
"@atlaskit/primitives": "^12.1.0",
"@atlaskit/progress-bar": "^0.5.6",
"@atlaskit/radio": "^6.5.5",
"@atlaskit/section-message": "^6.1.10",
"@atlaskit/select": "^15.2.11",
"@atlaskit/spinner": "^15.1.9",
Expand Down
Loading

0 comments on commit e130e9d

Please sign in to comment.