From 4f14549fa1858f979193a0ef517ebaedb497087b Mon Sep 17 00:00:00 2001 From: Nicholas Lydon Date: Mon, 27 Feb 2023 10:56:56 +0100 Subject: [PATCH] Detailed slack notifications on feature toggle update (#3155) ## About the changes Adds more specifics to addon notifications when a flexibleRollout is updated. - [x] Specific text for all strategy types - [x] Add constraint differences for all strategy types Closes [#3140](https://github.com/Unleash/unleash/issues/3140) --- .../addons/feature-event-formatter-md.test.ts | 512 ++++++++++++++++++ src/lib/addons/feature-event-formatter-md.ts | 256 ++++++++- 2 files changed, 744 insertions(+), 24 deletions(-) create mode 100644 src/lib/addons/feature-event-formatter-md.test.ts diff --git a/src/lib/addons/feature-event-formatter-md.test.ts b/src/lib/addons/feature-event-formatter-md.test.ts new file mode 100644 index 0000000000..93866e44f9 --- /dev/null +++ b/src/lib/addons/feature-event-formatter-md.test.ts @@ -0,0 +1,512 @@ +import { + FEATURE_STRATEGY_ADD, + FEATURE_STRATEGY_REMOVE, + FEATURE_STRATEGY_UPDATE, + IEvent, +} from '../types'; + +import { FeatureEventFormatterMd } from './feature-event-formatter-md'; +import { + DATE_AFTER, + DATE_BEFORE, + IN, + NOT_IN, + NUM_EQ, + NUM_GT, + NUM_GTE, + NUM_LT, + NUM_LTE, + SEMVER_EQ, + SEMVER_GT, + SEMVER_LT, + STR_CONTAINS, + STR_ENDS_WITH, + STR_STARTS_WITH, +} from '../util'; + +const testCases: [string, IEvent, string][] = [ + [ + 'when groupId changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + constraints: [], + parameters: { + groupId: 'different-feature', + rollout: '32', + stickiness: 'default', + }, + }, + preData: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + parameters: { + groupId: 'new-feature', + rollout: '32', + stickiness: 'default', + }, + constraints: [], + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy flexibleRollout in *production* groupId from new-feature to different-feature', + ], + [ + 'when rollout percentage changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + constraints: [], + parameters: { + groupId: 'new-feature', + rollout: '32', + stickiness: 'default', + }, + }, + preData: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + parameters: { + groupId: 'new-feature', + rollout: '67', + stickiness: 'default', + }, + constraints: [], + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy flexibleRollout in *production* rollout from 67% to 32%', + ], + [ + 'when stickiness changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + constraints: [], + parameters: { + groupId: 'new-feature', + rollout: '67', + stickiness: 'random', + }, + }, + preData: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + parameters: { + groupId: 'new-feature', + rollout: '67', + stickiness: 'default', + }, + constraints: [], + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy flexibleRollout in *production* stickiness from default to random', + ], + [ + 'when constraints and rollout percentage and stickiness changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + constraints: [ + { + values: ['x', 'y'], + inverted: false, + operator: IN, + contextName: 'appName', + caseInsensitive: false, + }, + ], + parameters: { + groupId: 'new-feature', + rollout: '32', + stickiness: 'random', + }, + }, + preData: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + parameters: { + groupId: 'new-feature', + rollout: '67', + stickiness: 'default', + }, + constraints: [], + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy flexibleRollout in *production* stickiness from default to random; rollout from 67% to 32%; constraints from empty set of constraints to [appName is one of (x,y)]', + ], + [ + 'when neither rollout percentage nor stickiness changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + constraints: [], + parameters: { + groupId: 'new-feature', + rollout: '67', + stickiness: 'default', + }, + }, + preData: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + parameters: { + groupId: 'new-feature', + rollout: '67', + stickiness: 'default', + }, + constraints: [], + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy flexibleRollout in *production*', + ], + [ + 'when strategy added', + { + id: 919, + type: FEATURE_STRATEGY_ADD, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:08.290Z'), + data: { + id: '3f4bf713-696c-43a4-8ce7-d6c607108858', + name: 'flexibleRollout', + constraints: [], + parameters: { + groupId: 'new-feature', + rollout: '67', + stickiness: 'default', + }, + }, + preData: null, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by adding strategy flexibleRollout in *production*', + ], + [ + 'when strategy removed', + { + id: 918, + type: FEATURE_STRATEGY_REMOVE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:00.229Z'), + data: null, + preData: { + id: '9591090e-acb0-4088-8958-21faaeb7147d', + name: 'default', + parameters: {}, + constraints: [], + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by removing strategy default in *production*', + ], + ...[ + [IN, 'is one of'], + [NOT_IN, 'is not one of'], + [STR_CONTAINS, 'is a string that contains'], + [STR_STARTS_WITH, 'is a string that starts with'], + [STR_ENDS_WITH, 'is a string that ends with'], + ].map( + ([operator, display]) => + <[string, IEvent, string]>[ + 'when default strategy updated', + { + id: 39, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'admin', + createdAt: new Date('2023-02-20T20:23:28.791Z'), + data: { + id: 'f2d34aac-52ec-49d2-82d3-08d710e89eaa', + name: 'default', + constraints: [ + { + values: ['x', 'y'], + inverted: false, + operator: operator, + contextName: 'appName', + caseInsensitive: false, + }, + { + values: ['x'], + inverted: true, + operator: operator, + contextName: 'appName', + caseInsensitive: false, + }, + ], + parameters: {}, + segments: [], + }, + preData: { + id: 'f2d34aac-52ec-49d2-82d3-08d710e89eaa', + name: 'default', + segments: [], + parameters: {}, + constraints: [], + }, + tags: [], + featureName: 'aaa', + project: 'default', + environment: 'production', + }, + `admin updated *[aaa](unleashUrl/projects/default/features/aaa)* in project *default* by updating strategy default in *production* constraints from empty set of constraints to [appName ${display} (x,y), appName not ${display} (x)]`, + ], + ), + ...[ + [NUM_EQ, 'is a number equal to'], + [NUM_GT, 'is a number greater than'], + [NUM_GTE, 'is a number greater than or equal to'], + [NUM_LT, 'is a number less than'], + [NUM_LTE, 'is a number less than or equal to'], + [DATE_BEFORE, 'is a date before'], + [DATE_AFTER, 'is a date after'], + [SEMVER_EQ, 'is a SemVer equal to'], + [SEMVER_GT, 'is a SemVer greater than'], + [SEMVER_LT, 'is a SemVer less than'], + ].map( + ([operator, display]) => + <[string, IEvent, string]>[ + 'when default strategy updated with numeric constraint ' + + operator, + { + id: 39, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'admin', + createdAt: new Date('2023-02-20T20:23:28.791Z'), + data: { + id: 'f2d34aac-52ec-49d2-82d3-08d710e89eaa', + name: 'default', + constraints: [], + parameters: {}, + segments: [], + }, + preData: { + id: 'f2d34aac-52ec-49d2-82d3-08d710e89eaa', + name: 'default', + segments: [], + parameters: {}, + constraints: [ + { + value: '4', + values: [], + inverted: false, + operator: operator, + contextName: 'appName', + caseInsensitive: false, + }, + ], + }, + tags: [], + featureName: 'aaa', + project: 'default', + environment: 'production', + }, + `admin updated *[aaa](unleashUrl/projects/default/features/aaa)* in project *default* by updating strategy default in *production* constraints from [appName ${display} 4] to empty set of constraints`, + ], + ), + [ + 'when userIds changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + name: 'userWithId', + constraints: [ + { + values: ['x', 'y'], + inverted: false, + operator: IN, + contextName: 'appName', + caseInsensitive: false, + }, + ], + parameters: { + userIds: 'a,b', + }, + sortOrder: 9999, + id: '9a995d94-5944-4897-a82f-0f7e65c2fb3f', + }, + preData: { + name: 'userWithId', + constraints: [], + parameters: { + userIds: '', + }, + sortOrder: 9999, + id: '9a995d94-5944-4897-a82f-0f7e65c2fb3f', + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy userWithId in *production* userIds from empty set of userIds to [a,b]; constraints from empty set of constraints to [appName is one of (x,y)]', + ], + [ + 'when IPs changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + name: 'remoteAddress', + constraints: [ + { + values: ['x', 'y'], + inverted: false, + operator: IN, + contextName: 'appName', + caseInsensitive: false, + }, + ], + parameters: { + IPs: '127.0.0.1', + }, + }, + preData: { + name: 'remoteAddress', + constraints: [], + parameters: { + IPs: '', + }, + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy remoteAddress in *production* IPs from empty set of IPs to [127.0.0.1]; constraints from empty set of constraints to [appName is one of (x,y)]', + ], + [ + 'when host names changed', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + name: 'applicationHostname', + constraints: [ + { + values: ['x', 'y'], + inverted: false, + operator: IN, + contextName: 'appName', + caseInsensitive: false, + }, + ], + parameters: { + hostNames: 'unleash.com', + }, + }, + preData: { + name: 'applicationHostname', + constraints: [], + parameters: { + hostNames: '', + }, + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy applicationHostname in *production* hostNames from empty set of hostNames to [unleash.com]; constraints from empty set of constraints to [appName is one of (x,y)]', + ], + [ + 'when no specific text for strategy exists yet', + { + id: 920, + type: FEATURE_STRATEGY_UPDATE, + createdBy: 'user@company.com', + createdAt: new Date('2022-06-01T10:03:11.549Z'), + data: { + name: 'newStrategy', + constraints: [ + { + values: ['x', 'y'], + inverted: false, + operator: IN, + contextName: 'appName', + caseInsensitive: false, + }, + ], + parameters: { + IPs: '127.0.0.1', + }, + }, + preData: { + name: 'newStrategy', + constraints: [], + parameters: { + IPs: '', + }, + }, + tags: [], + featureName: 'new-feature', + project: 'my-other-project', + environment: 'production', + }, + 'user@company.com updated *[new-feature](unleashUrl/projects/my-other-project/features/new-feature)* in project *my-other-project* by updating strategy newStrategy in *production*', + ], +]; + +testCases.forEach(([description, event, expected]) => + test('Should format specialised text for events ' + description, () => { + const formatter = new FeatureEventFormatterMd('unleashUrl'); + const actual = formatter.format(event); + expect(actual).toBe(expected); + }), +); diff --git a/src/lib/addons/feature-event-formatter-md.ts b/src/lib/addons/feature-event-formatter-md.ts index 4c20a6cec8..a6cc4ed726 100644 --- a/src/lib/addons/feature-event-formatter-md.ts +++ b/src/lib/addons/feature-event-formatter-md.ts @@ -1,20 +1,21 @@ import { - FEATURE_CREATED, - FEATURE_UPDATED, FEATURE_ARCHIVED, - FEATURE_STALE_ON, - FEATURE_STRATEGY_UPDATE, - FEATURE_STRATEGY_ADD, - FEATURE_ENVIRONMENT_ENABLED, - FEATURE_REVIVED, - FEATURE_STALE_OFF, + FEATURE_CREATED, FEATURE_ENVIRONMENT_DISABLED, - FEATURE_STRATEGY_REMOVE, + FEATURE_ENVIRONMENT_ENABLED, FEATURE_METADATA_UPDATED, FEATURE_PROJECT_CHANGE, - IEvent, + FEATURE_REVIVED, + FEATURE_STALE_OFF, + FEATURE_STALE_ON, + FEATURE_STRATEGY_ADD, + FEATURE_STRATEGY_REMOVE, + FEATURE_STRATEGY_UPDATE, + FEATURE_UPDATED, FEATURE_VARIANTS_UPDATED, -} from '../types/events'; + IConstraint, + IEvent, +} from '../types'; export interface FeatureEventFormatter { format: (event: IEvent) => string; @@ -27,9 +28,9 @@ export enum LinkStyle { } export class FeatureEventFormatterMd implements FeatureEventFormatter { - private unleashUrl: string; + private readonly unleashUrl: string; - private linkStyle: LinkStyle; + private readonly linkStyle: LinkStyle; constructor(unleashUrl: string, linkStyle: LinkStyle = LinkStyle.MD) { this.unleashUrl = unleashUrl; @@ -71,17 +72,222 @@ export class FeatureEventFormatterMd implements FeatureEventFormatter { } generateStrategyChangeText(event: IEvent): string { - const { createdBy, environment, project, data, preData, type } = event; + const { createdBy, environment, project, data, preData } = event; const feature = this.generateFeatureLink(event); - let strategyText: string = ''; - if (FEATURE_STRATEGY_UPDATE === type) { - strategyText = `by updating strategy ${data?.name} in *${environment}*`; - } else if (FEATURE_STRATEGY_ADD === type) { - strategyText = `by adding strategy ${data?.name} in *${environment}*`; - } else if (FEATURE_STRATEGY_REMOVE === type) { - strategyText = `by removing strategy ${preData?.name} in *${environment}*`; - } - return `${createdBy} updated *${feature}* in project *${project}* ${strategyText}`; + const strategyText = () => { + switch (data.name) { + case 'flexibleRollout': + return this.flexibleRolloutStrategyChangeText( + preData, + data, + environment, + ); + case 'default': + return this.defaultStrategyChangeText( + preData, + data, + environment, + ); + case 'userWithId': + return this.userWithIdStrategyChangeText( + preData, + data, + environment, + ); + case 'remoteAddress': + return this.remoteAddressStrategyChangeText( + preData, + data, + environment, + ); + case 'applicationHostname': + return this.applicationHostnameStrategyChangeText( + preData, + data, + environment, + ); + default: + return `by updating strategy ${data?.name} in *${environment}*`; + } + }; + + return `${createdBy} updated *${feature}* in project *${project}* ${strategyText()}`; + } + + private applicationHostnameStrategyChangeText( + preData, + data, + environment: string | undefined, + ) { + return this.listOfValuesStrategyChangeText( + preData, + data, + environment, + 'hostNames', + ); + } + + private remoteAddressStrategyChangeText( + preData, + data, + environment: string | undefined, + ) { + return this.listOfValuesStrategyChangeText( + preData, + data, + environment, + 'IPs', + ); + } + + private userWithIdStrategyChangeText( + preData, + data, + environment: string | undefined, + ) { + return this.listOfValuesStrategyChangeText( + preData, + data, + environment, + 'userIds', + ); + } + + private listOfValuesStrategyChangeText( + preData, + data, + environment: string | undefined, + propertyName: string, + ) { + const userIdText = (values) => + values.length === 0 + ? `empty set of ${propertyName}` + : `[${values}]`; + const usersText = + preData.parameters[propertyName] === data.parameters[propertyName] + ? '' + : ` ${propertyName} from ${userIdText( + preData.parameters[propertyName], + )} to ${userIdText(data.parameters[propertyName])}`; + const constraintText = this.constraintChangeText( + preData.constraints, + data.constraints, + ); + const strategySpecificText = [usersText, constraintText] + .filter((x) => x.length) + .join(';'); + return `by updating strategy ${data?.name} in *${environment}*${strategySpecificText}`; + } + + private flexibleRolloutStrategyChangeText( + preData, + data, + environment: string | undefined, + ) { + const { + rollout: oldRollout, + stickiness: oldStickiness, + groupId: oldGroupId, + } = preData.parameters; + const { rollout, stickiness, groupId } = data.parameters; + const stickinessText = + oldStickiness === stickiness + ? '' + : ` stickiness from ${oldStickiness} to ${stickiness}`; + const rolloutText = + oldRollout === rollout + ? '' + : ` rollout from ${oldRollout}% to ${rollout}%`; + const groupIdText = + oldGroupId === groupId + ? '' + : ` groupId from ${oldGroupId} to ${groupId}`; + const constraintText = this.constraintChangeText( + preData.constraints, + data.constraints, + ); + const strategySpecificText = [ + stickinessText, + rolloutText, + groupIdText, + constraintText, + ] + .filter((txt) => txt.length) + .join(';'); + return `by updating strategy ${data?.name} in *${environment}*${strategySpecificText}`; + } + + private defaultStrategyChangeText( + preData, + data, + environment: string | undefined, + ) { + return `by updating strategy ${ + data?.name + } in *${environment}*${this.constraintChangeText( + preData.constraints, + data.constraints, + )}`; + } + + private constraintChangeText( + oldConstraints: IConstraint[], + newConstraints: IConstraint[], + ) { + const formatConstraints = (constraints: IConstraint[]) => { + const constraintOperatorDescriptions = { + IN: 'is one of', + NOT_IN: 'is not one of', + STR_CONTAINS: 'is a string that contains', + STR_STARTS_WITH: 'is a string that starts with', + STR_ENDS_WITH: 'is a string that ends with', + NUM_EQ: 'is a number equal to', + NUM_GT: 'is a number greater than', + NUM_GTE: 'is a number greater than or equal to', + NUM_LT: 'is a number less than', + NUM_LTE: 'is a number less than or equal to', + DATE_BEFORE: 'is a date before', + DATE_AFTER: 'is a date after', + SEMVER_EQ: 'is a SemVer equal to', + SEMVER_GT: 'is a SemVer greater than', + SEMVER_LT: 'is a SemVer less than', + }; + const formatConstraint = (constraint: IConstraint) => { + const val = constraint.hasOwnProperty('value') + ? constraint.value + : `(${constraint.values.join(',')})`; + const operator = constraintOperatorDescriptions.hasOwnProperty( + constraint.operator, + ) + ? constraintOperatorDescriptions[constraint.operator] + : constraint.operator; + + return `${constraint.contextName} ${ + constraint.inverted ? 'not ' : '' + }${operator} ${val}`; + }; + + return constraints.length === 0 + ? 'empty set of constraints' + : `[${constraints.map(formatConstraint).join(', ')}]`; + }; + const oldConstraintText = formatConstraints(oldConstraints); + const newConstraintText = formatConstraints(newConstraints); + return oldConstraintText === newConstraintText + ? '' + : ` constraints from ${oldConstraintText} to ${newConstraintText}`; + } + + generateStrategyRemoveText(event: IEvent): string { + const { createdBy, environment, project, preData } = event; + const feature = this.generateFeatureLink(event); + return `${createdBy} updated *${feature}* in project *${project}* by removing strategy ${preData?.name} in *${environment}*`; + } + + generateStrategyAddText(event: IEvent): string { + const { createdBy, environment, project, data } = event; + const feature = this.generateFeatureLink(event); + return `${createdBy} updated *${feature}* in project *${project}* by adding strategy ${data?.name} in *${environment}*`; } generateMetadataText(event: IEvent): string { @@ -138,8 +344,10 @@ export class FeatureEventFormatterMd implements FeatureEventFormatter { case FEATURE_ENVIRONMENT_DISABLED: case FEATURE_ENVIRONMENT_ENABLED: return this.generateEnvironmentToggleText(event); - case FEATURE_STRATEGY_ADD: case FEATURE_STRATEGY_REMOVE: + return this.generateStrategyRemoveText(event); + case FEATURE_STRATEGY_ADD: + return this.generateStrategyAddText(event); case FEATURE_STRATEGY_UPDATE: return this.generateStrategyChangeText(event); case FEATURE_METADATA_UPDATED: