Skip to content

Commit 7aec768

Browse files
authored
feat(awards): add slack notifications (#31)
* add notification gateway * addf slack notification gateway * load slack notification gateway from config * pass config from demo app * only notify if there are new recipients * prettier * test the Awards.update func * change config spec a bit - icon_emoji and username fields seem to not actually work for the incoming webhook bot - i imagine we may have a `slack.bot` configuration, so isolating `webhook` to its own object * link to award in backstage * fetch user entities when sending notifications * remove "Woohoo!" * add description block * notify recipients on create award * remove comment * update test for new impl * add test for create notifying new recipients * fix tsc * add tests for slack notifications gateway * add docs * add copyrights * add AwardsNotifier abstraction to not bloat Awards class * make webhook an env var in examples as it contains secrets * make config required * don't pass around identityRef where it's not yet needed * add copyrights
1 parent 7b40487 commit 7aec768

File tree

14 files changed

+603
-10
lines changed

14 files changed

+603
-10
lines changed

packages/backend/src/plugins/awards.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@ export default async function createPlugin(
99
logger: env.logger,
1010
database: env.database,
1111
identity: env.identity,
12+
config: env.config,
13+
discovery: env.discovery,
14+
tokenManager: env.tokenManager,
1215
});
1316
}

plugins/awards-backend/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ function makeCreateEnv(config: Config) {
5858
}
5959
```
6060

61+
## Configuration
62+
63+
### Slack notifications
64+
65+
To enable Slack notifications, add the following to your `app-config.yaml` file:
66+
67+
```yaml
68+
awards:
69+
notifications:
70+
slack:
71+
webhook:
72+
# https://api.slack.com/messaging/webhooks
73+
url: ${MY_SLACK_WEBHOOK_URL_ENV_VAR}
74+
```
75+
76+
Users who have the `slack.com/user_id` annotation set (see [slack-catalog-backend](/plugins/slack-catalog-backend/README.md)) will be tagged in notifications that pertain to them.
77+
6178
## Developing this plugin
6279

6380
The plugin can be executed in isolation during development by running

plugins/awards-backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@
2525
"dependencies": {
2626
"@backstage/backend-common": "^0.20.1",
2727
"@backstage/backend-plugin-api": "^0.6.9",
28+
"@backstage/catalog-client": "^1.6.0",
29+
"@backstage/catalog-model": "^1.4.4",
2830
"@backstage/config": "^1.1.1",
2931
"@backstage/errors": "^1.2.3",
3032
"@backstage/plugin-auth-node": "^0.4.3",
3133
"@seatgeek/backstage-plugin-awards-common": "link:*",
34+
"@slack/webhook": "^7.0.2",
3235
"@types/express": "*",
3336
"express": "^4.17.1",
3437
"express-promise-router": "^4.1.0",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright SeatGeek
3+
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
4+
*/
5+
import { Award } from '@seatgeek/backstage-plugin-awards-common';
6+
import * as winston from 'winston';
7+
import { Awards } from './awards';
8+
import { AwardsStore } from './database/awards';
9+
import { AwardsNotifier } from './notifier';
10+
11+
const frank = 'user:default/frank-ocean';
12+
13+
function makeAward(): Award {
14+
return {
15+
uid: '123456',
16+
name: 'Test Award',
17+
description: 'This is a test award',
18+
image: 'image_data',
19+
owners: [frank],
20+
recipients: ['user:default/peyton-manning', 'user:default/serena-williams'],
21+
};
22+
}
23+
24+
describe('Awards', () => {
25+
let db: jest.Mocked<AwardsStore>;
26+
let notifier: jest.Mocked<AwardsNotifier>;
27+
let awards: Awards;
28+
29+
beforeEach(() => {
30+
db = {
31+
search: jest.fn(),
32+
add: jest.fn(),
33+
update: jest.fn(),
34+
delete: jest.fn(),
35+
};
36+
notifier = {
37+
notifyNewRecipients: jest.fn(),
38+
};
39+
const logger = winston.createLogger({
40+
transports: [new winston.transports.Console({ silent: true })],
41+
});
42+
awards = new Awards(db, notifier, logger);
43+
});
44+
45+
afterEach(() => {
46+
jest.resetAllMocks();
47+
});
48+
49+
describe('create', () => {
50+
it('should notify new recipients', async () => {
51+
const award = makeAward();
52+
db.add = jest.fn().mockResolvedValue(award);
53+
const result = await awards.create({
54+
name: award.name,
55+
description: award.description,
56+
image: award.image,
57+
owners: award.owners,
58+
recipients: award.recipients,
59+
});
60+
61+
// wait for the afterCreate promises to complete
62+
await new Promise(process.nextTick);
63+
64+
expect(result).toEqual(award);
65+
expect(db.add).toHaveBeenCalledWith(
66+
award.name,
67+
award.description,
68+
award.image,
69+
award.owners,
70+
award.recipients,
71+
);
72+
expect(notifier.notifyNewRecipients).toHaveBeenCalledWith(award, [
73+
'user:default/peyton-manning',
74+
'user:default/serena-williams',
75+
]);
76+
});
77+
});
78+
79+
describe('update', () => {
80+
it('should notify new recipients', async () => {
81+
const award = makeAward();
82+
db.search = jest.fn().mockResolvedValue([award]);
83+
const updated = {
84+
...award,
85+
recipients: [
86+
...award.recipients,
87+
'user:default/megan-rapinoe',
88+
'user:default/adrianne-lenker',
89+
],
90+
};
91+
db.update = jest.fn().mockResolvedValue(updated);
92+
const result = await awards.update(frank, award.uid, updated);
93+
94+
// wait for the afterUpdate promises to complete
95+
await new Promise(process.nextTick);
96+
97+
expect(result).toEqual(updated);
98+
expect(db.update).toHaveBeenCalledWith(
99+
updated.uid,
100+
updated.name,
101+
updated.description,
102+
updated.image,
103+
updated.owners,
104+
updated.recipients,
105+
);
106+
expect(notifier.notifyNewRecipients).toHaveBeenCalledWith(updated, [
107+
'user:default/megan-rapinoe',
108+
'user:default/adrianne-lenker',
109+
]);
110+
});
111+
});
112+
});

plugins/awards-backend/src/awards.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import { NotFoundError } from '@backstage/errors';
66
import { Award, AwardInput } from '@seatgeek/backstage-plugin-awards-common';
77
import { Logger } from 'winston';
88
import { AwardsStore } from './database/awards';
9+
import { AwardsNotifier } from './notifier';
910

1011
export class Awards {
1112
private readonly db: AwardsStore;
1213
private readonly logger: Logger;
14+
private readonly notifier: AwardsNotifier;
1315

14-
constructor(db: AwardsStore, logger: Logger) {
16+
constructor(db: AwardsStore, notifier: AwardsNotifier, logger: Logger) {
1517
this.db = db;
18+
this.notifier = notifier;
1619
this.logger = logger.child({ class: 'Awards' });
1720
this.logger.debug('Constructed');
1821
}
@@ -21,14 +24,36 @@ export class Awards {
2124
return await this.getAwardByUid(uid);
2225
}
2326

27+
private async afterCreate(award: Award): Promise<void> {
28+
if (award.recipients.length > 0) {
29+
await this.notifier.notifyNewRecipients(award, award.recipients);
30+
}
31+
}
32+
2433
async create(input: AwardInput): Promise<Award> {
25-
return await this.db.add(
34+
const award = await this.db.add(
2635
input.name,
2736
input.description,
2837
input.image,
2938
input.owners,
3039
input.recipients,
3140
);
41+
42+
this.afterCreate(award).catch(e => {
43+
this.logger.error('Error running afterCreate action', e);
44+
});
45+
46+
return award;
47+
}
48+
49+
private async afterUpdate(curr: Award, previous: Award): Promise<void> {
50+
const newRecipients = curr.recipients.filter(
51+
recipient => !previous.recipients.includes(recipient),
52+
);
53+
54+
if (newRecipients.length > 0) {
55+
await this.notifier.notifyNewRecipients(curr, newRecipients);
56+
}
3257
}
3358

3459
async update(
@@ -51,6 +76,10 @@ export class Awards {
5176
input.recipients,
5277
);
5378

79+
this.afterUpdate(updated, award).catch(e => {
80+
this.logger.error('Error running afterUpdate action', e);
81+
});
82+
5483
return updated;
5584
}
5685

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright SeatGeek
3+
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
4+
*/
5+
import { UserEntity } from '@backstage/catalog-model';
6+
import { Award } from '@seatgeek/backstage-plugin-awards-common';
7+
import { IncomingWebhook } from '@slack/webhook';
8+
import { SlackNotificationsGateway } from './notifications';
9+
10+
describe('SlackNotificationsGateway', () => {
11+
// @ts-ignore
12+
const slack: jest.Mocked<IncomingWebhook> = {
13+
send: jest.fn(),
14+
};
15+
16+
beforeEach(() => {
17+
jest.resetAllMocks();
18+
});
19+
20+
it('should send a message to slack', async () => {
21+
const gateway = new SlackNotificationsGateway(
22+
slack,
23+
'http://localhost:3000',
24+
);
25+
const award: Award = {
26+
uid: '123',
27+
name: 'Coolest Test',
28+
description: 'For great tests',
29+
image: 'image',
30+
owners: [],
31+
recipients: [],
32+
};
33+
const newRecipients: UserEntity[] = [
34+
{
35+
apiVersion: 'backstage.io/v1alpha1',
36+
kind: 'User',
37+
metadata: {
38+
name: 'taylor-swift',
39+
annotations: {
40+
'slack.com/user_id': '123',
41+
},
42+
},
43+
spec: {
44+
profile: {
45+
displayName: 'Taylor Swift',
46+
},
47+
},
48+
},
49+
{
50+
apiVersion: 'backstage.io/v1alpha1',
51+
kind: 'User',
52+
metadata: {
53+
name: 'lebron-james',
54+
annotations: {
55+
'slack.com/user_id': '456',
56+
},
57+
},
58+
spec: {
59+
profile: {
60+
displayName: 'Lebron James',
61+
},
62+
},
63+
},
64+
];
65+
66+
await gateway.notifyNewRecipientsAdded(award, newRecipients);
67+
68+
expect(slack.send).toHaveBeenCalledWith({
69+
blocks: [
70+
{
71+
type: 'header',
72+
text: {
73+
type: 'plain_text',
74+
text: ':trophy: The following users have received the Coolest Test Award :trophy:',
75+
emoji: true,
76+
},
77+
},
78+
{
79+
type: 'section',
80+
text: {
81+
type: 'mrkdwn',
82+
text: '<http://localhost:3000/catalog/default/User/taylor-swift|Taylor Swift> (<@123>), <http://localhost:3000/catalog/default/User/lebron-james|Lebron James> (<@456>)',
83+
},
84+
},
85+
{
86+
type: 'section',
87+
text: {
88+
type: 'mrkdwn',
89+
text: '> For great tests (<http://localhost:3000/awards/view/123|More info>)',
90+
},
91+
},
92+
],
93+
});
94+
});
95+
});

0 commit comments

Comments
 (0)