diff --git a/src/entry/extension-points/pre-uninstall.ts b/src/entry/extension-points/pre-uninstall.ts index 34afcf0..8e4e7e2 100644 --- a/src/entry/extension-points/pre-uninstall.ts +++ b/src/entry/extension-points/pre-uninstall.ts @@ -1,3 +1,4 @@ +import { getFormattedErrors, hasRejections } from '../../utils/promise-allsettled-helpers'; import { disconnectGroup } from '../../services/disconnect-group'; import { getForgeAppId } from '../../utils/get-forge-app-id'; import { getGroupIds } from '../../utils/storage-utils'; @@ -14,10 +15,14 @@ export default async function preUninstall(payload: PreUninstallPayload): Promis console.log(`Performing preUninstall for site ${cloudId}`); const forgeAppId = getForgeAppId(); - const groupIds = await getGroupIds(); try { - await Promise.all(groupIds.map((groupId) => disconnectGroup(groupId, cloudId, forgeAppId))); + const groupIds = await getGroupIds(); + + const results = await Promise.allSettled(groupIds.map((groupId) => disconnectGroup(groupId, cloudId, forgeAppId))); + if (hasRejections(results)) { + throw new Error(`Error while disconnecting groups: ${getFormattedErrors(results)}`); + } } catch (e) { console.error({ message: 'Error performing preUninstall', error: e }); } diff --git a/src/entry/webtriggers/gitlab-event-handlers/handle-merge-request-event.ts b/src/entry/webtriggers/gitlab-event-handlers/handle-merge-request-event.ts index fca5085..fb73910 100644 --- a/src/entry/webtriggers/gitlab-event-handlers/handle-merge-request-event.ts +++ b/src/entry/webtriggers/gitlab-event-handlers/handle-merge-request-event.ts @@ -3,6 +3,7 @@ import { getTrackingBranchName } from '../../../services/get-tracking-branch'; import { MergeRequestEvent } from '../../../types'; import { insertMetricValues } from '../../../services/insert-metric-values'; import { getMRCycleTime, getOpenMergeRequestsCount } from '../../../services/compute-event-and-metrics'; +import { ALL_SETTLED_STATUS, getFormattedErrors } from '../../../utils/promise-allsettled-helpers'; export const handleMergeRequestEvent = async ( event: MergeRequestEvent, @@ -18,11 +19,26 @@ export const handleMergeRequestEvent = async ( const trackingBranch = await getTrackingBranchName(groupToken, id, defaultBranch); if (trackingBranch === targetBranch) { - const [cycleTime, openMergeRequestsCount] = await Promise.all([ + const [cycleTimeResult, openMergeRequestsCountResult] = await Promise.allSettled([ getMRCycleTime(groupToken, id, trackingBranch), getOpenMergeRequestsCount(groupToken, id, trackingBranch), ]); + if ( + cycleTimeResult.status === ALL_SETTLED_STATUS.REJECTED || + openMergeRequestsCountResult.status === ALL_SETTLED_STATUS.REJECTED + ) { + throw new Error( + `Failed to get merge request cycle time or open merge request count: ${getFormattedErrors([ + cycleTimeResult, + openMergeRequestsCountResult, + ])}`, + ); + } + + const cycleTime = cycleTimeResult.value; + const openMergeRequestsCount = openMergeRequestsCountResult.value; + const metricInput = { projectID: id.toString(), metrics: [ diff --git a/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.test.ts b/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.test.ts index 958f8a2..932a7c8 100644 --- a/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.test.ts +++ b/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.test.ts @@ -12,6 +12,7 @@ import { getTrackingBranchName } from '../../../services/get-tracking-branch'; import { MOCK_CLOUD_ID, TEST_TOKEN } from '../../../__tests__/fixtures/gitlab-data'; import { ComponentSyncDetails } from '../../../types'; import { EXTERNAL_SOURCE } from '../../../constants'; +import { ALL_SETTLED_STATUS, getFormattedErrors } from '../../../utils/promise-allsettled-helpers'; jest.mock('../../../services/sync-component-with-file', () => { return { @@ -24,6 +25,10 @@ jest.mock('../../../services/get-tracking-branch'); jest.spyOn(global.console, 'error').mockImplementation(() => ({})); const MOCK_ERROR = new Error('Unexpected Error'); +const RejectedPromiseSettled: PromiseSettledResult = { + status: ALL_SETTLED_STATUS.REJECTED, + reason: MOCK_ERROR, +}; describe('Gitlab push events', () => { const event = generatePushEvent(); @@ -221,6 +226,9 @@ describe('Gitlab push events', () => { await handlePushEvent(event, TEST_TOKEN, MOCK_CLOUD_ID); - expect(console.error).toBeCalledWith('Error while handling push event', MOCK_ERROR); + expect(console.error).toBeCalledWith( + 'Error while handling push event', + new Error(`Error removing components: ${getFormattedErrors([RejectedPromiseSettled])}`), + ); }); }); diff --git a/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.ts b/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.ts index 8930f5e..7aaa8f0 100644 --- a/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.ts +++ b/src/entry/webtriggers/gitlab-event-handlers/handle-push-event.ts @@ -5,6 +5,7 @@ import { ComponentSyncDetails, PushEvent } from '../../../types'; import { getTrackingBranchName } from '../../../services/get-tracking-branch'; import { unlinkComponentFromFile } from '../../../client/compass'; import { EXTERNAL_SOURCE } from '../../../constants'; +import { hasRejections, getFormattedErrors } from '../../../utils/promise-allsettled-helpers'; export const handlePushEvent = async (event: PushEvent, groupToken: string, cloudId: string): Promise => { try { @@ -56,6 +57,12 @@ export const handlePushEvent = async (event: PushEvent, groupToken: string, clou }), ); + const creationAndUpdateResults = await Promise.allSettled([...creates, ...updates]); + + if (hasRejections(creationAndUpdateResults)) { + throw new Error(`Error creating or updating components: ${getFormattedErrors(creationAndUpdateResults)}`); + } + const removals = componentsToUnlink.map((componentToUnlink) => unlinkComponentFromFile({ cloudId, @@ -67,7 +74,12 @@ export const handlePushEvent = async (event: PushEvent, groupToken: string, clou : [], }), ); - await Promise.all([...creates, ...updates, ...removals]); + + const removalResults = await Promise.allSettled(removals); + + if (hasRejections(removalResults)) { + throw new Error(`Error removing components: ${getFormattedErrors(removalResults)}`); + } } catch (e) { console.error('Error while handling push event', e); } diff --git a/src/services/clear-storage.ts b/src/services/clear-storage.ts index ef3ad87..71a783f 100644 --- a/src/services/clear-storage.ts +++ b/src/services/clear-storage.ts @@ -1,6 +1,7 @@ import { storage, ListResult, startsWith } from '@forge/api'; import { CLEAR_STORAGE_CHUNK_SIZE, CLEAR_STORAGE_DELAY, STORAGE_KEYS, STORAGE_SECRETS } from '../constants'; import { deleteKeysFromStorageByChunks } from '../utils/storage-utils'; +import { getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; const getLastFailedProjectsKeys = async (): Promise => { const lastFailedProjects: ListResult = await storage @@ -45,5 +46,13 @@ export const clearImportKeys = async (): Promise => { }; export const deleteGroupDataFromStorage = async (groupId: string): Promise => { - await Promise.all([clearStorageSecretsForGroup(groupId), clearStorageEntriesForGroup(groupId), clearImportKeys()]); + const deleteGroupDataResult = await Promise.allSettled([ + clearStorageSecretsForGroup(groupId), + clearStorageEntriesForGroup(groupId), + clearImportKeys(), + ]); + + if (hasRejections(deleteGroupDataResult)) { + throw new Error(`Error deleting group data: ${getFormattedErrors(deleteGroupDataResult)}`); + } }; diff --git a/src/services/compute-event-and-metrics/get-recent-deployments.ts b/src/services/compute-event-and-metrics/get-recent-deployments.ts index 425f77a..b57e3b2 100644 --- a/src/services/compute-event-and-metrics/get-recent-deployments.ts +++ b/src/services/compute-event-and-metrics/get-recent-deployments.ts @@ -5,6 +5,7 @@ import { getRecentDeployments, gitlabAPiDeploymentToCompassDataProviderDeploymen import { getProjectEnvironments } from '../environment'; import { getDateInThePast } from '../../utils/time-utils'; import { isSendStagingEventsEnabled } from '../feature-flags'; +import { getFormattedErrors, hasRejections } from '../../utils/promise-allsettled-helpers'; const newGetDeploymentsForEnvironments = async ( groupToken: string, @@ -35,7 +36,17 @@ const newGetDeploymentsForEnvironments = async ( }); // combine results from multiple projectEnvironments into single array - return Promise.all(deploymentEvents).then((results) => results.flat()); + const settledResults = await Promise.allSettled(deploymentEvents); + + if (hasRejections(settledResults)) { + throw new Error(`Error getting deployment: ${getFormattedErrors(settledResults)}`); + } + + const result = settledResults.map( + (settledResult) => (settledResult as PromiseFulfilledResult).value, + ); + + return result.flat(); }; export const getDeploymentsForEnvironmentTiers = async ( @@ -62,7 +73,18 @@ export const getDeploymentsForEnvironmentTiers = async ( [], ); - const deployments = (await Promise.all(getDeploymentsPromises)).flat(); + const deploymentsResult = await Promise.allSettled(getDeploymentsPromises); + + if (hasRejections(deploymentsResult)) { + throw new Error(`Error getting deployments: ${getFormattedErrors(deploymentsResult)}`); + } + + const deploymentsValues = deploymentsResult.map( + (deploymentResult) => (deploymentResult as PromiseFulfilledResult).value, + ); + + const deployments = deploymentsValues.flat(); + const deploymentEvents = deployments .map((deployment) => gitlabAPiDeploymentToCompassDataProviderDeploymentEvent(deployment, projectName, EnvironmentTier.PRODUCTION), diff --git a/src/services/data-provider-link-parser.ts b/src/services/data-provider-link-parser.ts index 508d4da..599ad7a 100644 --- a/src/services/data-provider-link-parser.ts +++ b/src/services/data-provider-link-parser.ts @@ -5,6 +5,7 @@ import { getOwnedProjectsBySearchCriteria } from '../client/gitlab'; import { STORAGE_SECRETS } from '../constants'; import { getGroupIds } from '../utils/storage-utils'; import { GitlabAPIProject } from '../types'; +import { getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; export const extractProjectInformation = (projectUrl: string): { projectName: string; pathName: string } | null => { const parsedUrl = parse(projectUrl); @@ -18,12 +19,20 @@ export const extractProjectInformation = (projectUrl: string): { projectName: st }; export const getAllGroupTokens = async (): Promise => { - const groupIds = await getGroupIds(); - const groupTokens = await Promise.all( - groupIds.map((groupId) => storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`)), - ); + try { + const groupIds = await getGroupIds(); + const groupTokensResult = await Promise.allSettled( + groupIds.map((groupId) => storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`)), + ); + + if (hasRejections(groupTokensResult)) { + throw new Error(`Error getting group tokens ${getFormattedErrors(groupTokensResult)}`); + } - return groupTokens; + return groupTokensResult.map((groupTokenResult) => (groupTokenResult as PromiseFulfilledResult).value); + } catch (e) { + throw new Error(`Error while getting all group tokens: ${e}`); + } }; function doesURLMatch(projectUrl: string, path: string, name: string) { diff --git a/src/services/deployment.ts b/src/services/deployment.ts index 895eb64..9091d41 100644 --- a/src/services/deployment.ts +++ b/src/services/deployment.ts @@ -11,6 +11,7 @@ import { fetchPaginatedData } from '../utils/fetchPaginatedData'; import { getProjectEnvironments } from './environment'; import { isSendStagingEventsEnabled } from './feature-flags'; import { truncateProjectNameString } from '../utils/event-mapping'; +import { getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; export const gitLabStateToCompassFormat = (state: string): CompassDeploymentEventState => { switch (state) { @@ -153,7 +154,15 @@ export const getDeploymentAfter28Days = async ( [], ); - const promisesResponse = await Promise.all(getDeploymentsPromises); + const promisesResponseResults = await Promise.allSettled(getDeploymentsPromises); + + if (hasRejections(promisesResponseResults)) { + throw new Error(`Error getting deployments ${getFormattedErrors(promisesResponseResults)}`); + } + + const promisesResponse = promisesResponseResults.map( + (promisesResponseResult) => (promisesResponseResult as PromiseFulfilledResult<{ data: Deployment[] }>).value, + ); return promisesResponse ? promisesResponse.map((deployment) => deployment.data).flat() : []; }; diff --git a/src/services/fetch-projects.test.ts b/src/services/fetch-projects.test.ts index 673a90f..2b1f916 100644 --- a/src/services/fetch-projects.test.ts +++ b/src/services/fetch-projects.test.ts @@ -16,6 +16,7 @@ import { sortedProjects, } from '../__tests__/helpers/gitlab-helper'; import { MergeRequest } from '../types'; +import { ALL_SETTLED_STATUS, getFormattedErrors } from '../utils/promise-allsettled-helpers'; jest.mock('../client/gitlab'); jest.mock('../client/compass'); @@ -60,6 +61,13 @@ const mergeRequestMock: MergeRequest[] = [ }, ]; +const MOCK_ERROR = 'Error: Error while getting repository additional fields.'; + +const RejectedPromiseSettled: PromiseSettledResult = { + status: ALL_SETTLED_STATUS.REJECTED, + reason: MOCK_ERROR, +}; + describe('Fetch Projects Service', () => { beforeEach(() => { jest.clearAllMocks(); @@ -128,15 +136,19 @@ describe('Fetch Projects Service', () => { mockGetProjects.mockRejectedValue(undefined); await expect(getGroupProjects(MOCK_CLOUD_ID, MOCK_GROUP_ID, 1, 1)).rejects.toThrow( - new Error('Error while fetching group projects from Gitlab!'), + `Error while getting group projects: ${new Error('Error while fetching group projects from Gitlab!')}`, ); }); it('returns error in case when getComponentByExternalAlias fails', async () => { mockGetComponentByExternalAlias.mockRejectedValue(undefined); + const settledError = new Error( + `Error checking project with existing components: ${getFormattedErrors([RejectedPromiseSettled])}`, + ); + await expect(getGroupProjects(MOCK_CLOUD_ID, MOCK_GROUP_ID, 1, 1)).rejects.toThrow( - new Error('Error: Error while getting repository additional fields.'), + `Error while getting group projects: ${settledError}`, ); }); diff --git a/src/services/fetch-projects.ts b/src/services/fetch-projects.ts index 26c1b05..43d5ecf 100644 --- a/src/services/fetch-projects.ts +++ b/src/services/fetch-projects.ts @@ -4,8 +4,15 @@ import { storage } from '@forge/api'; import { getComponentByExternalAlias } from '../client/compass'; import { COMPASS_YML_BRANCH, EXTERNAL_SOURCE, STORAGE_SECRETS } from '../constants'; import { getMergeRequests, getProjects, GitLabHeaders } from '../client/gitlab'; -import { GroupProjectsResponse, MergeRequestState, Project, ProjectReadyForImport } from '../types'; +import { + CompareProjectWithExistingComponent, + GroupProjectsResponse, + MergeRequestState, + Project, + ProjectReadyForImport, +} from '../types'; import { getProjectLabels } from './get-labels'; +import { ALL_SETTLED_STATUS, getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; const mapComponentLinks = (links: Link[] = []): CreateLinkInput[] => links.map((link) => { @@ -22,7 +29,7 @@ const fetchProjects = async ( const PER_PAGE = 10; const { data: projects, headers } = await getProjects(groupToken, groupId, page, PER_PAGE, search); - const generatedProjectsWithLanguages = await Promise.all( + const generatedProjectsWithLanguagesResult = await Promise.allSettled( projects.map(async (project) => { const labels = await getProjectLabels(project.id, groupToken, project.topics); @@ -40,6 +47,17 @@ const fetchProjects = async ( }), ); + if (hasRejections(generatedProjectsWithLanguagesResult)) { + throw new Error( + `Error getting projects with languages: ${getFormattedErrors(generatedProjectsWithLanguagesResult)}`, + ); + } + + const generatedProjectsWithLanguages = generatedProjectsWithLanguagesResult.map( + (generatedProjectWithLanguagesResult) => + (generatedProjectWithLanguagesResult as PromiseFulfilledResult).value, + ); + return { total: Number(headers.get(GitLabHeaders.PAGINATION_TOTAL)), projects: generatedProjectsWithLanguages }; } catch (err) { const ERROR_MESSAGE = 'Error while fetching group projects from Gitlab!'; @@ -49,9 +67,13 @@ const fetchProjects = async ( } }; -const compareProjectWithExistingComponent = async (cloudId: string, projectId: number, groupToken: string) => { +const compareProjectWithExistingComponent = async ( + cloudId: string, + projectId: number, + groupToken: string, +): Promise => { try { - const [{ component }, { data: mergeRequestWithCompassYML }] = await Promise.all([ + const [componentByExtenalAliasResult, mergeRequestsResults] = await Promise.allSettled([ getComponentByExternalAlias({ cloudId, externalId: projectId.toString(), @@ -67,6 +89,21 @@ const compareProjectWithExistingComponent = async (cloudId: string, projectId: n }), ]); + if ( + componentByExtenalAliasResult.status === ALL_SETTLED_STATUS.REJECTED || + mergeRequestsResults.status === ALL_SETTLED_STATUS.REJECTED + ) { + throw new Error( + `Error getting component by external alias or merge requests: ${getFormattedErrors([ + componentByExtenalAliasResult, + mergeRequestsResults, + ])}`, + ); + } + + const { component } = componentByExtenalAliasResult.value; + const { data: mergeRequestWithCompassYML } = mergeRequestsResults.value; + return { isManaged: Boolean(component?.dataManager), hasComponent: Boolean(component?.id) || Boolean(mergeRequestWithCompassYML.length), @@ -106,21 +143,36 @@ export const getGroupProjects = async ( groupTokenId: number, search?: string, ): Promise => { - const groupToken = await storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupTokenId}`); + try { + const groupToken = await storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupTokenId}`); - const { projects, total } = await fetchProjects(groupToken, groupId, page, search); + const { projects, total } = await fetchProjects(groupToken, groupId, page, search); - const checkedDataWithExistingComponents = await Promise.all( - projects.map(({ id: projectId }) => { - return compareProjectWithExistingComponent(cloudId, projectId, groupToken); - }), - ).catch((err) => { - throw new Error(err); - }); + const checkedDataWithExistingComponentsResults = await Promise.allSettled( + projects.map(({ id: projectId }) => { + return compareProjectWithExistingComponent(cloudId, projectId, groupToken); + }), + ); - const resultProjects = projects.map((project, i) => { - return { ...project, ...checkedDataWithExistingComponents[i] }; - }); + if (hasRejections(checkedDataWithExistingComponentsResults)) { + throw new Error( + `Error checking project with existing components: ${getFormattedErrors( + checkedDataWithExistingComponentsResults, + )}`, + ); + } + + const checkedDataWithExistingComponents = checkedDataWithExistingComponentsResults.map( + (checkedDataWithExistingComponentsResult) => + (checkedDataWithExistingComponentsResult as PromiseFulfilledResult).value, + ); + + const resultProjects = projects.map((project, i) => { + return { ...project, ...checkedDataWithExistingComponents[i] }; + }); - return { total, projects: resultProjects }; + return { total, projects: resultProjects }; + } catch (e) { + throw new Error(`Error while getting group projects: ${e}`); + } }; diff --git a/src/services/get-backfill-data.ts b/src/services/get-backfill-data.ts index a664dd9..fb25f3c 100644 --- a/src/services/get-backfill-data.ts +++ b/src/services/get-backfill-data.ts @@ -1,4 +1,3 @@ -import { DataProviderBuildEvent, DataProviderDeploymentEvent } from '@atlassian/forge-graphql'; import { getDeploymentsForEnvironmentTiers, getMRCycleTime, @@ -6,41 +5,47 @@ import { getProjectBuildsFor28Days, } from './compute-event-and-metrics'; import { hasDeploymentAfter28Days } from '../utils/has-deployment-after-28days'; -import { EnvironmentTier } from '../types'; +import { BackfillData, EnvironmentTier } from '../types'; import { isSendStagingEventsEnabled } from './feature-flags'; +import { getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; export const getBackfillData = async ( groupToken: string, projectId: number, projectName: string, branchName: string, -): Promise<{ - builds: DataProviderBuildEvent[]; - deployments: DataProviderDeploymentEvent[]; - metrics: { - mrCycleTime: number; - openMergeRequestsCount: number; - }; -}> => { - const [allBuildsFor28Days, mrCycleTime, deployments, openMergeRequestsCount] = await Promise.all([ - getProjectBuildsFor28Days(groupToken, projectId, projectName, branchName), - getMRCycleTime(groupToken, projectId, branchName), - getDeploymentsForEnvironmentTiers( - groupToken, - projectId, - projectName, - isSendStagingEventsEnabled ? [EnvironmentTier.PRODUCTION, EnvironmentTier.STAGING] : undefined, - ), - getOpenMergeRequestsCount(groupToken, projectId, branchName), - hasDeploymentAfter28Days(projectId, groupToken), - ]); +): Promise => { + try { + const backfillResults = await Promise.allSettled([ + getProjectBuildsFor28Days(groupToken, projectId, projectName, branchName), + getMRCycleTime(groupToken, projectId, branchName), + getDeploymentsForEnvironmentTiers( + groupToken, + projectId, + projectName, + isSendStagingEventsEnabled ? [EnvironmentTier.PRODUCTION, EnvironmentTier.STAGING] : undefined, + ), + getOpenMergeRequestsCount(groupToken, projectId, branchName), + hasDeploymentAfter28Days(projectId, groupToken), + ]); - return { - builds: allBuildsFor28Days, - deployments, - metrics: { - mrCycleTime, - openMergeRequestsCount, - }, - }; + if (hasRejections(backfillResults)) { + throw new Error(`Error getting backfill data ${getFormattedErrors(backfillResults)}`); + } + + const [allBuildsFor28Days, mrCycleTime, deployments, openMergeRequestsCount] = backfillResults.map( + (backfillResult) => (backfillResult as PromiseFulfilledResult).value, + ); + + return { + builds: allBuildsFor28Days, + deployments, + metrics: { + mrCycleTime, + openMergeRequestsCount, + }, + }; + } catch (e) { + throw new Error(`Error while getting backfill data: ${e}`); + } }; diff --git a/src/services/group.ts b/src/services/group.ts index efe36a8..5f63657 100644 --- a/src/services/group.ts +++ b/src/services/group.ts @@ -5,6 +5,7 @@ import { getGroupAccessTokens, getGroupsData } from '../client/gitlab'; import { REQUIRED_SCOPES, STORAGE_KEYS, STORAGE_SECRETS } from '../constants'; import { AuthErrorTypes } from '../resolverTypes'; import { deleteGroupDataFromStorage } from './clear-storage'; +import { ALL_SETTLED_STATUS, getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; export class InvalidGroupTokenError extends Error { constructor(public errorType: AuthErrorTypes) { @@ -58,7 +59,7 @@ const getGroups = async (owned?: string, minAccessLevel?: number): Promise storage.getSecret( `${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${group.key.replace(STORAGE_KEYS.GROUP_KEY_PREFIX, '')}`, @@ -66,6 +67,14 @@ const getGroups = async (owned?: string, minAccessLevel?: number): Promise) => (tokenResult as PromiseFulfilledResult).value, + ); + const groupPromises = tokens.map((token: string) => getGroupsData(token, owned, minAccessLevel)); // We need to remove revoked/invalid (on Gitlab side) tokens from storage @@ -80,10 +89,13 @@ const getGroups = async (owned?: string, minAccessLevel?: number): Promise, i: number, ) => { - if (currentGroupResult.status === 'rejected' && currentGroupResult.reason.toString().includes('Unauthorized')) { + if ( + currentGroupResult.status === ALL_SETTLED_STATUS.REJECTED && + currentGroupResult.reason.toString().includes('Unauthorized') + ) { result.invalidGroupIds.push(groups[i].key.replace(STORAGE_KEYS.GROUP_KEY_PREFIX, '')); } - if (currentGroupResult.status === 'fulfilled') { + if (currentGroupResult.status === ALL_SETTLED_STATUS.FULFILLED) { if (minAccessLevel) { result.accessedGroups.push(...currentGroupResult.value); } else { @@ -96,7 +108,13 @@ const getGroups = async (owned?: string, minAccessLevel?: number): Promise deleteGroupDataFromStorage(id))); + const settledResult = await Promise.allSettled( + reducedGroupsResult.invalidGroupIds.map((id: string) => deleteGroupDataFromStorage(id)), + ); + + if (hasRejections(settledResult)) { + throw new Error(`Error deleting group data from storage: ${getFormattedErrors(settledResult)}`); + } return reducedGroupsResult.accessedGroups; }; diff --git a/src/services/import-projects.test.ts b/src/services/import-projects.test.ts index 0140e10..4c31e42 100644 --- a/src/services/import-projects.test.ts +++ b/src/services/import-projects.test.ts @@ -17,6 +17,7 @@ import { import { setLastSyncTime } from './last-sync-time'; import { mocked } from 'jest-mock'; import { ImportErrorTypes } from '../resolverTypes'; +import { ALL_SETTLED_STATUS, getFormattedErrors } from '../utils/promise-allsettled-helpers'; const storageGetSuccess = jest.fn().mockReturnValue(['jobId1', 'jobId2']); const storageGetEmptyArray = jest.fn().mockReturnValue([]); @@ -161,7 +162,20 @@ describe('clearImportResult test cases', () => { it('clearImportResult test case: storage delete failed', async () => { storage.query = storageQuerySuccess; - await expect(clearImportResult()).rejects.toThrow(errorForClearImportProject); + const RejectedPromiseSettled: PromiseSettledResult[] = [ + { + status: ALL_SETTLED_STATUS.REJECTED, + reason: errorForClearImportProject, + }, + { + status: ALL_SETTLED_STATUS.REJECTED, + reason: errorForClearImportProject, + }, + ]; + + await expect(clearImportResult()).rejects.toThrow( + `Error deleting key: ${getFormattedErrors(RejectedPromiseSettled)}`, + ); expect(storage.query).toHaveBeenCalled(); expect(storage.delete).toHaveBeenCalled(); diff --git a/src/services/import-projects.ts b/src/services/import-projects.ts index 152ca6b..1aa0c46 100644 --- a/src/services/import-projects.ts +++ b/src/services/import-projects.ts @@ -9,6 +9,7 @@ import { Queues, ImportableProject, ProjectImportResult, ImportStatus } from '.. import { ImportErrorTypes } from '../resolverTypes'; import { setLastSyncTime } from './last-sync-time'; import { deleteKeysFromStorageByChunks } from '../utils/storage-utils'; +import { getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; export const QUEUE_ONE_TIME_LIMIT = 500; const QUEUE_PUSH_EVENTS_LIMIT = 50; @@ -31,7 +32,7 @@ export const importProjects = async ( const queue = new Queue({ key: Queues.IMPORT }); - if (projectsReadyToImport.length > queueOneTimeLimit) { + if (projectsReadyToImport.length > Number(queueOneTimeLimit)) { throw new OneTimeLimitImportError( `Sorry, unfortunately you can import maximum ${QUEUE_ONE_TIME_LIMIT} projects at one time.`, ); @@ -76,13 +77,21 @@ export const getImportStatus = async (): Promise => { throw new Error('No running job'); } const queue = new Queue({ key: Queues.IMPORT }); - const jobStatuses = await Promise.all( + const jobStatusesResult = await Promise.allSettled( jobIds.map((id: string) => { const job = queue.getJob(id); return job.getStats().then((s) => s.json()); }), ); + if (hasRejections(jobStatusesResult)) { + throw new Error(`Error getting job statuses: ${getFormattedErrors(jobStatusesResult)}`); + } + + const jobStatuses = jobStatusesResult.map( + (jobStatusResult) => (jobStatusResult as PromiseFulfilledResult).value, + ); + return jobStatuses.reduce( (acc: ImportStatus, importStatus: ImportStatus) => { return { diff --git a/src/services/insert-metric-values.ts b/src/services/insert-metric-values.ts index fbc01e6..6757a8b 100644 --- a/src/services/insert-metric-values.ts +++ b/src/services/insert-metric-values.ts @@ -1,3 +1,4 @@ +import { getFormattedErrors, hasRejections } from '../utils/promise-allsettled-helpers'; import { insertMetricValueByExternalId } from '../client/compass'; import { MetricsEventPayload } from '../types'; @@ -12,12 +13,16 @@ export const insertMetricValues = async (metricsPayload: MetricsEventPayload, cl cloudId, }); - await Promise.all( + const settledResult = await Promise.allSettled( metrics.map(async (metric) => { await insertMetricValueByExternalId(cloudId, projectID, metric); }), ); + if (hasRejections(settledResult)) { + throw new Error(`Error inserting metric values: ${getFormattedErrors(settledResult)}`); + } + console.log({ message: 'insertMetricValues finished.', duration: Date.now() - startTime, diff --git a/src/services/sync-component-with-file/find-config-file-changes.ts b/src/services/sync-component-with-file/find-config-file-changes.ts index 78b82bc..cacc865 100644 --- a/src/services/sync-component-with-file/find-config-file-changes.ts +++ b/src/services/sync-component-with-file/find-config-file-changes.ts @@ -14,6 +14,7 @@ import { detectMovedFilesAndUpdateComponentChanges, handleModifiedFilesAndUpdateComponentChanges, } from './config-file-changes-transformer'; +import { ALL_SETTLED_STATUS, getFormattedErrors } from '../../utils/promise-allsettled-helpers'; const getRemovedFiles = async ( token: string, @@ -138,12 +139,30 @@ export const findConfigAsCodeFileChanges = async (event: PushEvent, token: strin `Found ${added.length} added diffs, ${removed.length} removed diffs, and ${modified.length} modified diffs in push event. Now processing what files might have been moved or renamed.`, ); - const [createPayload, unlinkPayload, modifiedFiles] = await Promise.all([ + const [createPayloadResult, unlinkPayloadResult, modifiedFilesResult] = await Promise.allSettled([ getAddedFiles(token, added, event), getRemovedFiles(token, removed, event), getModifiedFiles(token, modified, event), ]); + if ( + createPayloadResult.status === ALL_SETTLED_STATUS.REJECTED || + unlinkPayloadResult.status === ALL_SETTLED_STATUS.REJECTED || + modifiedFilesResult.status === ALL_SETTLED_STATUS.REJECTED + ) { + throw new Error( + `Error addind or removed or modifying file: ${getFormattedErrors([ + createPayloadResult, + unlinkPayloadResult, + modifiedFilesResult, + ])}`, + ); + } + + const createPayload = createPayloadResult.value; + const unlinkPayload = unlinkPayloadResult.value; + const modifiedFiles = modifiedFilesResult.value; + const componentChanges: ComponentChanges = { componentsToCreate: createPayload, componentsToUpdate: [], diff --git a/src/services/webhooks.ts b/src/services/webhooks.ts index 7228fa3..f899924 100644 --- a/src/services/webhooks.ts +++ b/src/services/webhooks.ts @@ -3,15 +3,31 @@ import { 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'; export const setupAndValidateWebhook = async (groupId: number): Promise => { console.log('Setting up webhook'); try { - const [existingWebhook, groupToken] = await Promise.all([ + const [existingWebhookResult, groupTokenResult] = await Promise.allSettled([ storage.get(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`), storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`), ]); + if ( + existingWebhookResult.status === ALL_SETTLED_STATUS.REJECTED || + groupTokenResult.status === ALL_SETTLED_STATUS.REJECTED + ) { + throw new Error( + `Error getting existing webhook or group token: ${getFormattedErrors([ + existingWebhookResult, + groupTokenResult, + ])}`, + ); + } + + const existingWebhook = existingWebhookResult.value; + const groupToken = groupTokenResult.value; + const isWebhookValid = existingWebhook && (await getGroupWebhook(groupId, existingWebhook, groupToken)) !== null; if (isWebhookValid) { @@ -29,11 +45,15 @@ export const setupAndValidateWebhook = async (groupId: number): Promise signature: webhookSignature, }); - await Promise.all([ + const settledResult = await Promise.allSettled([ storage.set(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`, webhookId), storage.set(`${STORAGE_KEYS.WEBHOOK_SIGNATURE_PREFIX}${groupId}`, webhookSignature), ]); + if (hasRejections(settledResult)) { + throw new Error(`Error setting webhookId or webhookSignature: ${getFormattedErrors(settledResult)}`); + } + console.log('Successfully created webhook'); return webhookId; } catch (e) { @@ -43,12 +63,29 @@ export const setupAndValidateWebhook = async (groupId: number): Promise }; export const deleteWebhook = async (groupId: number): Promise => { - const [webhookId, groupToken] = await Promise.all([ - storage.get(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`), - storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`), - ]); + try { + const [webhookIdResult, groupTokenResult] = await Promise.allSettled([ + storage.get(`${STORAGE_KEYS.WEBHOOK_KEY_PREFIX}${groupId}`), + storage.getSecret(`${STORAGE_SECRETS.GROUP_TOKEN_KEY_PREFIX}${groupId}`), + ]); + + if ( + webhookIdResult.status === ALL_SETTLED_STATUS.REJECTED || + groupTokenResult.status === ALL_SETTLED_STATUS.REJECTED + ) { + throw new Error( + `Error getting webhookId or groupToken: ${getFormattedErrors([webhookIdResult, groupTokenResult])}`, + ); + } - if (webhookId) { - await deleteGroupWebhook(groupId, webhookId, groupToken); + const webhookId = webhookIdResult.value; + const groupToken = groupTokenResult.value; + + if (webhookId) { + await deleteGroupWebhook(groupId, webhookId, groupToken); + } + } catch (e) { + console.error('Error while getting webhookId or groupToken', e); + throw new Error(`Error while getting webhookId or groupToken: ${e}`); } }; diff --git a/src/types.ts b/src/types.ts index 084bb85..de7dc9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,9 @@ -import { CreateLinkInput, CustomFieldFromYAML } from '@atlassian/forge-graphql'; +import { + CreateLinkInput, + CustomFieldFromYAML, + DataProviderBuildEvent, + DataProviderDeploymentEvent, +} from '@atlassian/forge-graphql'; type WebtriggerRequest = { body: string; @@ -250,6 +255,11 @@ type ProjectReadyForImport = { } & ProjectImportStatus & Project; +type CompareProjectWithExistingComponent = Pick< + ProjectReadyForImport, + 'isManaged' | 'hasComponent' | 'isCompassFilePrOpened' | 'componentId' | 'componentLinks' | 'typeId' +>; + type ImportableProject = ProjectReadyForImport & { typeId: string; }; @@ -400,6 +410,15 @@ type TeamsWithMembershipStatus = { otherTeams: MappedTeam[]; }; +type BackfillData = { + builds: DataProviderBuildEvent[]; + deployments: DataProviderDeploymentEvent[]; + metrics: { + mrCycleTime: number; + openMergeRequestsCount: number; + }; +}; + export type { WebtriggerRequest, WebtriggerResponse, @@ -440,6 +459,8 @@ export type { MappedTeam, Team, TeamsWithMembershipStatus, + CompareProjectWithExistingComponent, + BackfillData, }; export { diff --git a/src/utils/fetchPaginatedData.ts b/src/utils/fetchPaginatedData.ts index c45c466..81df434 100644 --- a/src/utils/fetchPaginatedData.ts +++ b/src/utils/fetchPaginatedData.ts @@ -1,4 +1,5 @@ import { GitLabHeaders, GitlabPaginatedFetch } from '../client/gitlab'; +import { getFormattedErrors, hasRejections } from './promise-allsettled-helpers'; export const fetchPaginatedData = async ( fetchFn: GitlabPaginatedFetch, @@ -20,7 +21,15 @@ export const fetchPaginatedData = async ( promises.push(fetchFn(pageNumber, perPage, fetchFnParameters)); } - const restOfData = await Promise.all(promises); + const restOfDataResults = await Promise.allSettled(promises); + + if (hasRejections(restOfDataResults)) { + throw new Error(`Error getting data results: ${getFormattedErrors(restOfDataResults)}`); + } + + const restOfData = restOfDataResults.map( + (restOfDataResult) => (restOfDataResult as PromiseFulfilledResult<{ data: D[]; headers: Headers }>).value, + ); return [...firstPageData, ...restOfData.map(({ data }) => data).flat()]; }; diff --git a/src/utils/promise-allsettled-helpers.test.ts b/src/utils/promise-allsettled-helpers.test.ts new file mode 100644 index 0000000..69885cc --- /dev/null +++ b/src/utils/promise-allsettled-helpers.test.ts @@ -0,0 +1,88 @@ +import { hasRejections, getFormattedErrors, ALL_SETTLED_STATUS } from './promise-allsettled-helpers'; + +describe('hasRejections', () => { + it('should return false when the provided array is empty', () => { + const results: PromiseSettledResult[] = []; + expect(hasRejections(results)).toBe(false); + }); + + it('should return true when the provided array has a single rejected promise', () => { + const results: PromiseSettledResult[] = [{ status: ALL_SETTLED_STATUS.REJECTED, reason: 'error' }]; + expect(hasRejections(results)).toBe(true); + }); + + it('should return false when the provided array has a single fulfilled promise', () => { + const results: PromiseSettledResult[] = [{ status: ALL_SETTLED_STATUS.FULFILLED, value: 'success' }]; + expect(hasRejections(results)).toBe(false); + }); + + it('should return false when the provided array has all successful promises', () => { + const results: PromiseSettledResult[] = [ + { status: ALL_SETTLED_STATUS.FULFILLED, value: 'success1' }, + { status: ALL_SETTLED_STATUS.FULFILLED, value: 'success2' }, + { status: ALL_SETTLED_STATUS.FULFILLED, value: 'success3' }, + ]; + expect(hasRejections(results)).toBe(false); + }); + + it('should return true when the provided array has all rejected promises', () => { + const results: PromiseSettledResult[] = [ + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error1' }, + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error2' }, + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error3' }, + ]; + expect(hasRejections(results)).toBe(true); + }); + + it('should return true when the provided array has mixed fulfilled and rejected promises', () => { + const results: PromiseSettledResult[] = [ + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error1' }, + { status: ALL_SETTLED_STATUS.FULFILLED, value: 'success1' }, + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error2' }, + { status: ALL_SETTLED_STATUS.FULFILLED, value: 'success2' }, + ]; + expect(hasRejections(results)).toBe(true); + }); +}); + +describe('getFormattedErrors', () => { + it('should return an empty string when no promise results are given', () => { + const results: PromiseSettledResult[] = []; + expect(getFormattedErrors(results)).toBe(''); + }); + + it('should return an empty string when a single successful result is given', () => { + const results: PromiseSettledResult[] = [{ status: ALL_SETTLED_STATUS.FULFILLED, value: 'success' }]; + expect(getFormattedErrors(results)).toBe(''); + }); + + it('should return the error message when a single error result is given', () => { + const results: PromiseSettledResult[] = [{ status: ALL_SETTLED_STATUS.REJECTED, reason: 'error' }]; + expect(getFormattedErrors(results)).toBe('1.) error'); + }); + + it('should handle rejected promise with an empty error message', () => { + const results: PromiseSettledResult[] = [{ status: ALL_SETTLED_STATUS.REJECTED, reason: '' }]; + expect(getFormattedErrors(results)).toBe('1.) '); + }); + + it('should return all error messages when multiple rejected promises are given', () => { + const results: PromiseSettledResult[] = [ + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error1' }, + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error2' }, + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error3' }, + ]; + expect(getFormattedErrors(results)).toBe('1.) error1, 2.) error2, 3.) error3'); + }); + + // eslint-disable-next-line max-len + it('should return all error messages in the expected format when an array of successful and rejected promises is given', () => { + const results: PromiseSettledResult[] = [ + { status: ALL_SETTLED_STATUS.FULFILLED, value: 'success1' }, + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error1' }, + { status: ALL_SETTLED_STATUS.FULFILLED, value: 'success2' }, + { status: ALL_SETTLED_STATUS.REJECTED, reason: 'error2' }, + ]; + expect(getFormattedErrors(results)).toBe('1.) error1, 2.) error2'); + }); +}); diff --git a/src/utils/promise-allsettled-helpers.ts b/src/utils/promise-allsettled-helpers.ts new file mode 100644 index 0000000..06a8308 --- /dev/null +++ b/src/utils/promise-allsettled-helpers.ts @@ -0,0 +1,17 @@ +export enum ALL_SETTLED_STATUS { + FULFILLED = 'fulfilled', + REJECTED = 'rejected', +} + +export const hasRejections = (results: PromiseSettledResult[]): boolean => { + return results.some((result) => result.status === ALL_SETTLED_STATUS.REJECTED); +}; + +export const getFormattedErrors = (results: PromiseSettledResult[]): string => { + return results + .filter((result) => result.status === ALL_SETTLED_STATUS.REJECTED) + .map((result, index) => { + return `${index + 1}.) ${(result as PromiseRejectedResult).reason}`; + }) + .join(', '); +}; diff --git a/src/utils/storage-utils.ts b/src/utils/storage-utils.ts index bcb801b..af290d8 100644 --- a/src/utils/storage-utils.ts +++ b/src/utils/storage-utils.ts @@ -3,6 +3,7 @@ import { chunk } from 'lodash'; import { sleep } from './time-utils'; import { STORAGE_KEYS } from '../constants'; +import { getFormattedErrors, hasRejections } from './promise-allsettled-helpers'; export const deleteKeysFromStorageByChunks = async ( keys: string[], @@ -12,7 +13,11 @@ export const deleteKeysFromStorageByChunks = async ( const keyChunks = chunk(keys, chunkSize); for (const keyChunk of keyChunks) { - await Promise.all(keyChunk.map((key: string) => storage.delete(key))); + const settledResult = await Promise.allSettled(keyChunk.map((key: string) => storage.delete(key))); + + if (hasRejections(settledResult)) { + throw new Error(`Error deleting key: ${getFormattedErrors(settledResult)}`); + } await sleep(delay); } };