From 1033276e97e288e4ba0e05e0d0cc20f0ccff2fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 22 Jul 2024 11:54:19 +0100 Subject: [PATCH] chore: register integration events in Slack App integration (#7631) https://linear.app/unleash/issue/2-2459/register-integration-events-slack-app Registers integration events in the **Slack App** integration. Similar to: - #7626 - #7621 --- src/lib/addons/slack-app.test.ts | 62 ++++++++++-- src/lib/addons/slack-app.ts | 166 ++++++++++++++++++++----------- src/lib/addons/slack.test.ts | 3 +- src/lib/addons/slack.ts | 28 +++--- 4 files changed, 180 insertions(+), 79 deletions(-) diff --git a/src/lib/addons/slack-app.test.ts b/src/lib/addons/slack-app.test.ts index c693ed811d..fb4ec4958f 100644 --- a/src/lib/addons/slack-app.test.ts +++ b/src/lib/addons/slack-app.test.ts @@ -4,9 +4,11 @@ import { type ChatPostMessageArguments, ErrorCode } from '@slack/web-api'; import { type IAddonConfig, type IFlagResolver, + serializeDates, SYSTEM_USER_ID, } from '../types'; import type { IntegrationEventsService } from '../services'; +import type { Logger } from '../logger'; const slackApiCalls: ChatPostMessageArguments[] = []; @@ -19,6 +21,8 @@ const loggerMock = { }; const getLogger = jest.fn(() => loggerMock); +const registerEventMock = jest.fn(); + const INTEGRATION_ID = 1337; const ARGS: IAddonConfig = { getLogger, @@ -47,6 +51,22 @@ jest.mock('@slack/web-api', () => ({ }, })); +jest.mock( + './addon', + () => + class Addon { + logger: Logger; + + constructor(_, { getLogger }) { + this.logger = getLogger('addon/test'); + } + + async registerEvent(event) { + return registerEventMock(event); + } + }, +); + describe('SlackAppAddon', () => { let addon: SlackAppAddon; const accessToken = 'test-access-token'; @@ -78,6 +98,7 @@ describe('SlackAppAddon', () => { jest.useFakeTimers(); slackApiCalls.length = 0; postMessage.mockClear(); + registerEventMock.mockClear(); addon = new SlackAppAddon(ARGS); }); @@ -187,8 +208,7 @@ describe('SlackAppAddon', () => { ); expect(loggerMock.warn).toHaveBeenCalledWith( - `Error handling event ${event.type}. A platform error occurred: ${JSON.stringify(mockError.data)}`, - expect.any(Object), + `All (1) Slack client calls failed with the following errors: A platform error occurred: ${JSON.stringify(mockError.data)}`, ); }); @@ -219,11 +239,39 @@ describe('SlackAppAddon', () => { expect(postMessage).toHaveBeenCalledTimes(3); expect(loggerMock.warn).toHaveBeenCalledWith( - `Error handling event ${FEATURE_ENVIRONMENT_ENABLED}. A platform error occurred: ${JSON.stringify(mockError.data)}`, - expect.any(Object), - ); - expect(loggerMock.info).toHaveBeenCalledWith( - `Handled event ${FEATURE_ENVIRONMENT_ENABLED} dispatching 2 out of 3 messages successfully.`, + `Some (1 of 3) Slack client calls failed. Errors: A platform error occurred: ${JSON.stringify(mockError.data)}`, ); }); + + test('Should call registerEvent', async () => { + const eventWith2Tags: IEvent = { + ...event, + tags: [ + { type: 'slack', value: 'general' }, + { type: 'slack', value: 'another-channel-1' }, + ], + }; + + await addon.handleEvent( + eventWith2Tags, + { + accessToken, + defaultChannels: 'another-channel-1, another-channel-2', + }, + INTEGRATION_ID, + ); + + expect(registerEventMock).toHaveBeenCalledTimes(1); + expect(registerEventMock).toHaveBeenCalledWith({ + integrationId: INTEGRATION_ID, + state: 'success', + stateDetails: 'All (3) Slack client calls were successful.', + event: serializeDates(eventWith2Tags), + details: { + channels: ['general', 'another-channel-1', 'another-channel-2'], + message: + '*some@user.com* enabled ** for the *development* environment in project **', + }, + }); + }); }); diff --git a/src/lib/addons/slack-app.ts b/src/lib/addons/slack-app.ts index 6ea54f5329..79e92702fd 100644 --- a/src/lib/addons/slack-app.ts +++ b/src/lib/addons/slack-app.ts @@ -13,13 +13,14 @@ import { import Addon from './addon'; import slackAppDefinition from './slack-app-definition'; -import type { IAddonConfig } from '../types/model'; +import { type IAddonConfig, serializeDates } from '../types'; import { type FeatureEventFormatter, FeatureEventFormatterMd, LinkStyle, } from './feature-event-formatter-md'; import type { IEvent } from '../types/events'; +import type { IntegrationEventState } from '../features/integration-events/integration-events-store'; interface ISlackAppAddonParameters { accessToken: string; @@ -46,30 +47,42 @@ export default class SlackAppAddon extends Addon { parameters: ISlackAppAddonParameters, integrationId: number, ): Promise { + let state: IntegrationEventState = 'success'; + const stateDetails: string[] = []; + let channels: string[] = []; + let message = ''; + try { const { accessToken, defaultChannels } = parameters; if (!accessToken) { - this.logger.warn('No access token provided.'); + const noAccessTokenMessage = 'No access token provided.'; + this.logger.warn(noAccessTokenMessage); + this.registerEarlyFailureEvent( + integrationId, + event, + noAccessTokenMessage, + ); return; } const taggedChannels = this.findTaggedChannels(event); - const eventChannels = [ - ...new Set( - taggedChannels.concat( - this.getDefaultChannels(defaultChannels), - ), - ), - ]; + channels = this.getUniqueArray( + taggedChannels.concat(this.getDefaultChannels(defaultChannels)), + ); - if (!eventChannels.length) { - this.logger.debug( - `No Slack channels found for event ${event.type}.`, + if (!channels.length) { + const noSlackChannelsMessage = `No Slack channels found for event ${event.type}.`; + this.logger.debug(noSlackChannelsMessage); + this.registerEarlyFailureEvent( + integrationId, + event, + noSlackChannelsMessage, ); return; } + this.logger.debug( - `Found candidate channels: ${JSON.stringify(eventChannels)}.`, + `Found candidate channels: ${JSON.stringify(channels)}.`, ); if (!this.slackClient || this.accessToken !== accessToken) { @@ -84,6 +97,7 @@ export default class SlackAppAddon extends Addon { } const { text, url } = this.msgFormatter.format(event); + message = text; const blocks: (Block | KnownBlock)[] = [ { @@ -113,7 +127,7 @@ export default class SlackAppAddon extends Addon { }); } - const requests = eventChannels.map((name) => { + const requests = channels.map((name) => { return this.slackClient!.chat.postMessage({ channel: name, text, @@ -123,23 +137,73 @@ export default class SlackAppAddon extends Addon { const results = await Promise.allSettled(requests); - results - .filter(({ status }) => status === 'rejected') - .map(({ reason }: PromiseRejectedResult) => - this.logError(event, reason), - ); - - this.logger.info( - `Handled event ${event.type} dispatching ${ - results.filter(({ status }) => status === 'fulfilled') - .length - } out of ${requests.length} messages successfully.`, + const failedRequests = results.filter( + ({ status }) => status === 'rejected', ); + const errors = this.getUniqueArray( + failedRequests.map(({ reason }: PromiseRejectedResult) => + this.parseError(reason), + ), + ).join(' '); + + if (failedRequests.length === 0) { + const successMessage = `All (${results.length}) Slack client calls were successful.`; + stateDetails.push(successMessage); + this.logger.info(successMessage); + } else if (failedRequests.length === results.length) { + state = 'failed'; + const failedMessage = `All (${results.length}) Slack client calls failed with the following errors: ${errors}`; + stateDetails.push(failedMessage); + this.logger.warn(failedMessage); + } else { + state = 'successWithErrors'; + const successWithErrorsMessage = `Some (${failedRequests.length} of ${results.length}) Slack client calls failed. Errors: ${errors}`; + stateDetails.push(successWithErrorsMessage); + this.logger.warn(successWithErrorsMessage); + } } catch (error) { - this.logError(event, error); + state = 'failed'; + const eventErrorMessage = `Error handling event ${event.type}.`; + stateDetails.push(eventErrorMessage); + this.logger.warn(eventErrorMessage); + const errorMessage = this.parseError(error); + stateDetails.push(errorMessage); + this.logger.warn(errorMessage, error); + } finally { + this.registerEvent({ + integrationId, + state, + stateDetails: stateDetails.join('\n'), + event: serializeDates(event), + details: { + channels, + message, + }, + }); } } + getUniqueArray(arr: T[]): T[] { + return [...new Set(arr)]; + } + + registerEarlyFailureEvent( + integrationId: number, + event: IEvent, + earlyFailureMessage: string, + ): void { + this.registerEvent({ + integrationId, + state: 'failed', + stateDetails: earlyFailureMessage, + event: serializeDates(event), + details: { + channels: [], + message: '', + }, + }); + } + findTaggedChannels({ tags }: Pick): string[] { if (tags) { return tags @@ -156,39 +220,27 @@ export default class SlackAppAddon extends Addon { return []; } - logError(event: IEvent, error: Error | CodedError): void { - if (!('code' in error)) { - this.logger.warn(`Error handling event ${event.type}.`, error); - return; + parseError(error: Error | CodedError): string { + if ('code' in error) { + if (error.code === ErrorCode.PlatformError) { + const { data } = error as WebAPIPlatformError; + return `A platform error occurred: ${JSON.stringify(data)}`; + } + if (error.code === ErrorCode.RequestError) { + const { original } = error as WebAPIRequestError; + return `A request error occurred: ${JSON.stringify(original)}`; + } + if (error.code === ErrorCode.RateLimitedError) { + const { retryAfter } = error as WebAPIRateLimitedError; + return `A rate limit error occurred: retry after ${retryAfter} seconds`; + } + if (error.code === ErrorCode.HTTPError) { + const { statusCode } = error as WebAPIHTTPError; + return `An HTTP error occurred: status code ${statusCode}`; + } } - if (error.code === ErrorCode.PlatformError) { - const { data } = error as WebAPIPlatformError; - this.logger.warn( - `Error handling event ${event.type}. A platform error occurred: ${JSON.stringify(data)}`, - error, - ); - } else if (error.code === ErrorCode.RequestError) { - const { original } = error as WebAPIRequestError; - this.logger.warn( - `Error handling event ${event.type}. A request error occurred: ${JSON.stringify(original)}`, - error, - ); - } else if (error.code === ErrorCode.RateLimitedError) { - const { retryAfter } = error as WebAPIRateLimitedError; - this.logger.warn( - `Error handling event ${event.type}. A rate limit error occurred: retry after ${retryAfter} seconds`, - error, - ); - } else if (error.code === ErrorCode.HTTPError) { - const { statusCode } = error as WebAPIHTTPError; - this.logger.warn( - `Error handling event ${event.type}. An HTTP error occurred: status code ${statusCode}`, - error, - ); - } else { - this.logger.warn(`Error handling event ${event.type}.`, error); - } + return error.message; } } diff --git a/src/lib/addons/slack.test.ts b/src/lib/addons/slack.test.ts index 89ff7c50a7..8e67cb3adf 100644 --- a/src/lib/addons/slack.test.ts +++ b/src/lib/addons/slack.test.ts @@ -336,7 +336,8 @@ describe('Slack integration', () => { url: parameters.url, channels: ['general'], username: 'Unleash', - text: '*some@user.com* disabled ** for the *development* environment in project **', + message: + '*some@user.com* disabled ** for the *development* environment in project **', }, }); }); diff --git a/src/lib/addons/slack.ts b/src/lib/addons/slack.ts index 4fb32c1233..ce0cad61bd 100644 --- a/src/lib/addons/slack.ts +++ b/src/lib/addons/slack.ts @@ -57,11 +57,11 @@ export default class SlackAddon extends Addon { try { extraHeaders = JSON.parse(customHeaders); } catch (e) { - const detailMessage = - 'Could not parse the JSON in the customHeaders parameter.'; state = 'successWithErrors'; - stateDetails.push(detailMessage); - this.logger.warn(detailMessage); + const badHeadersMessage = + 'Could not parse the JSON in the customHeaders parameter.'; + stateDetails.push(badHeadersMessage); + this.logger.warn(badHeadersMessage); } } @@ -109,19 +109,19 @@ export default class SlackAddon extends Addon { this.logger.info(`Handled event ${event.type}.`); if (failedRequests.length === 0) { - const detailMessage = `All (${results.length}) Slack webhook requests were successful with status codes: ${codes}.`; - stateDetails.push(detailMessage); - this.logger.info(detailMessage); + const successMessage = `All (${results.length}) Slack webhook requests were successful with status codes: ${codes}.`; + stateDetails.push(successMessage); + this.logger.info(successMessage); } else if (failedRequests.length === results.length) { - const detailMessage = `All (${results.length}) Slack webhook requests failed with status codes: ${codes}.`; state = 'failed'; - stateDetails.push(detailMessage); - this.logger.warn(detailMessage); + const failedMessage = `All (${results.length}) Slack webhook requests failed with status codes: ${codes}.`; + stateDetails.push(failedMessage); + this.logger.warn(failedMessage); } else { - const detailMessage = `Some (${failedRequests.length} of ${results.length}) Slack webhook requests failed. Status codes: ${codes}.`; state = 'successWithErrors'; - stateDetails.push(detailMessage); - this.logger.warn(detailMessage); + const successWithErrorsMessage = `Some (${failedRequests.length} of ${results.length}) Slack webhook requests failed. Status codes: ${codes}.`; + stateDetails.push(successWithErrorsMessage); + this.logger.warn(successWithErrorsMessage); } this.registerEvent({ @@ -133,7 +133,7 @@ export default class SlackAddon extends Addon { url, channels: slackChannels, username, - text, + message: text, }, }); }