1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-27 00:19:39 +01:00

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
This commit is contained in:
Nuno Góis 2024-07-22 11:54:19 +01:00 committed by GitHub
parent 44192934f8
commit 1033276e97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 180 additions and 79 deletions

View File

@ -4,9 +4,11 @@ import { type ChatPostMessageArguments, ErrorCode } from '@slack/web-api';
import { import {
type IAddonConfig, type IAddonConfig,
type IFlagResolver, type IFlagResolver,
serializeDates,
SYSTEM_USER_ID, SYSTEM_USER_ID,
} from '../types'; } from '../types';
import type { IntegrationEventsService } from '../services'; import type { IntegrationEventsService } from '../services';
import type { Logger } from '../logger';
const slackApiCalls: ChatPostMessageArguments[] = []; const slackApiCalls: ChatPostMessageArguments[] = [];
@ -19,6 +21,8 @@ const loggerMock = {
}; };
const getLogger = jest.fn(() => loggerMock); const getLogger = jest.fn(() => loggerMock);
const registerEventMock = jest.fn();
const INTEGRATION_ID = 1337; const INTEGRATION_ID = 1337;
const ARGS: IAddonConfig = { const ARGS: IAddonConfig = {
getLogger, 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', () => { describe('SlackAppAddon', () => {
let addon: SlackAppAddon; let addon: SlackAppAddon;
const accessToken = 'test-access-token'; const accessToken = 'test-access-token';
@ -78,6 +98,7 @@ describe('SlackAppAddon', () => {
jest.useFakeTimers(); jest.useFakeTimers();
slackApiCalls.length = 0; slackApiCalls.length = 0;
postMessage.mockClear(); postMessage.mockClear();
registerEventMock.mockClear();
addon = new SlackAppAddon(ARGS); addon = new SlackAppAddon(ARGS);
}); });
@ -187,8 +208,7 @@ describe('SlackAppAddon', () => {
); );
expect(loggerMock.warn).toHaveBeenCalledWith( expect(loggerMock.warn).toHaveBeenCalledWith(
`Error handling event ${event.type}. A platform error occurred: ${JSON.stringify(mockError.data)}`, `All (1) Slack client calls failed with the following errors: A platform error occurred: ${JSON.stringify(mockError.data)}`,
expect.any(Object),
); );
}); });
@ -219,11 +239,39 @@ describe('SlackAppAddon', () => {
expect(postMessage).toHaveBeenCalledTimes(3); expect(postMessage).toHaveBeenCalledTimes(3);
expect(loggerMock.warn).toHaveBeenCalledWith( expect(loggerMock.warn).toHaveBeenCalledWith(
`Error handling event ${FEATURE_ENVIRONMENT_ENABLED}. A platform error occurred: ${JSON.stringify(mockError.data)}`, `Some (1 of 3) Slack client calls failed. Errors: 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.`,
); );
}); });
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 *<http://some-url.com/projects/default/features/some-toggle|some-toggle>* for the *development* environment in project *<http://some-url.com/projects/default|default>*',
},
});
});
}); });

View File

@ -13,13 +13,14 @@ import {
import Addon from './addon'; import Addon from './addon';
import slackAppDefinition from './slack-app-definition'; import slackAppDefinition from './slack-app-definition';
import type { IAddonConfig } from '../types/model'; import { type IAddonConfig, serializeDates } from '../types';
import { import {
type FeatureEventFormatter, type FeatureEventFormatter,
FeatureEventFormatterMd, FeatureEventFormatterMd,
LinkStyle, LinkStyle,
} from './feature-event-formatter-md'; } from './feature-event-formatter-md';
import type { IEvent } from '../types/events'; import type { IEvent } from '../types/events';
import type { IntegrationEventState } from '../features/integration-events/integration-events-store';
interface ISlackAppAddonParameters { interface ISlackAppAddonParameters {
accessToken: string; accessToken: string;
@ -46,30 +47,42 @@ export default class SlackAppAddon extends Addon {
parameters: ISlackAppAddonParameters, parameters: ISlackAppAddonParameters,
integrationId: number, integrationId: number,
): Promise<void> { ): Promise<void> {
let state: IntegrationEventState = 'success';
const stateDetails: string[] = [];
let channels: string[] = [];
let message = '';
try { try {
const { accessToken, defaultChannels } = parameters; const { accessToken, defaultChannels } = parameters;
if (!accessToken) { 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; return;
} }
const taggedChannels = this.findTaggedChannels(event); const taggedChannels = this.findTaggedChannels(event);
const eventChannels = [ channels = this.getUniqueArray(
...new Set( taggedChannels.concat(this.getDefaultChannels(defaultChannels)),
taggedChannels.concat( );
this.getDefaultChannels(defaultChannels),
),
),
];
if (!eventChannels.length) { if (!channels.length) {
this.logger.debug( const noSlackChannelsMessage = `No Slack channels found for event ${event.type}.`;
`No Slack channels found for event ${event.type}.`, this.logger.debug(noSlackChannelsMessage);
this.registerEarlyFailureEvent(
integrationId,
event,
noSlackChannelsMessage,
); );
return; return;
} }
this.logger.debug( this.logger.debug(
`Found candidate channels: ${JSON.stringify(eventChannels)}.`, `Found candidate channels: ${JSON.stringify(channels)}.`,
); );
if (!this.slackClient || this.accessToken !== accessToken) { if (!this.slackClient || this.accessToken !== accessToken) {
@ -84,6 +97,7 @@ export default class SlackAppAddon extends Addon {
} }
const { text, url } = this.msgFormatter.format(event); const { text, url } = this.msgFormatter.format(event);
message = text;
const blocks: (Block | KnownBlock)[] = [ 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({ return this.slackClient!.chat.postMessage({
channel: name, channel: name,
text, text,
@ -123,23 +137,73 @@ export default class SlackAppAddon extends Addon {
const results = await Promise.allSettled(requests); const results = await Promise.allSettled(requests);
results const failedRequests = results.filter(
.filter(({ status }) => status === 'rejected') ({ 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 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) { } 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<T>(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<IEvent, 'tags'>): string[] { findTaggedChannels({ tags }: Pick<IEvent, 'tags'>): string[] {
if (tags) { if (tags) {
return tags return tags
@ -156,39 +220,27 @@ export default class SlackAppAddon extends Addon {
return []; return [];
} }
logError(event: IEvent, error: Error | CodedError): void { parseError(error: Error | CodedError): string {
if (!('code' in error)) { if ('code' in error) {
this.logger.warn(`Error handling event ${event.type}.`, error); if (error.code === ErrorCode.PlatformError) {
return; 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) { return error.message;
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);
}
} }
} }

View File

@ -336,7 +336,8 @@ describe('Slack integration', () => {
url: parameters.url, url: parameters.url,
channels: ['general'], channels: ['general'],
username: 'Unleash', username: 'Unleash',
text: '*some@user.com* disabled *<http://some-url.com/projects/default/features/some-toggle|some-toggle>* for the *development* environment in project *<http://some-url.com/projects/default|default>*', message:
'*some@user.com* disabled *<http://some-url.com/projects/default/features/some-toggle|some-toggle>* for the *development* environment in project *<http://some-url.com/projects/default|default>*',
}, },
}); });
}); });

View File

@ -57,11 +57,11 @@ export default class SlackAddon extends Addon {
try { try {
extraHeaders = JSON.parse(customHeaders); extraHeaders = JSON.parse(customHeaders);
} catch (e) { } catch (e) {
const detailMessage =
'Could not parse the JSON in the customHeaders parameter.';
state = 'successWithErrors'; state = 'successWithErrors';
stateDetails.push(detailMessage); const badHeadersMessage =
this.logger.warn(detailMessage); '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}.`); this.logger.info(`Handled event ${event.type}.`);
if (failedRequests.length === 0) { if (failedRequests.length === 0) {
const detailMessage = `All (${results.length}) Slack webhook requests were successful with status codes: ${codes}.`; const successMessage = `All (${results.length}) Slack webhook requests were successful with status codes: ${codes}.`;
stateDetails.push(detailMessage); stateDetails.push(successMessage);
this.logger.info(detailMessage); this.logger.info(successMessage);
} else if (failedRequests.length === results.length) { } else if (failedRequests.length === results.length) {
const detailMessage = `All (${results.length}) Slack webhook requests failed with status codes: ${codes}.`;
state = 'failed'; state = 'failed';
stateDetails.push(detailMessage); const failedMessage = `All (${results.length}) Slack webhook requests failed with status codes: ${codes}.`;
this.logger.warn(detailMessage); stateDetails.push(failedMessage);
this.logger.warn(failedMessage);
} else { } else {
const detailMessage = `Some (${failedRequests.length} of ${results.length}) Slack webhook requests failed. Status codes: ${codes}.`;
state = 'successWithErrors'; state = 'successWithErrors';
stateDetails.push(detailMessage); const successWithErrorsMessage = `Some (${failedRequests.length} of ${results.length}) Slack webhook requests failed. Status codes: ${codes}.`;
this.logger.warn(detailMessage); stateDetails.push(successWithErrorsMessage);
this.logger.warn(successWithErrorsMessage);
} }
this.registerEvent({ this.registerEvent({
@ -133,7 +133,7 @@ export default class SlackAddon extends Addon {
url, url,
channels: slackChannels, channels: slackChannels,
username, username,
text, message: text,
}, },
}); });
} }