Skip to content

Commit 0a237dc

Browse files
committed
feat: Support for batch notifications
1 parent 56a954d commit 0a237dc

File tree

5 files changed

+134
-61
lines changed

5 files changed

+134
-61
lines changed

lib/api/Notification.ts

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import { Logger } from 'loglevel'
88
import { Moment } from 'moment'
99
import log = require('loglevel')
1010
import moment = require('moment')
11-
import doc = Mocha.reporters.doc
1211

1312
export class Notification {
1413
private _configuration: Configuration
1514
private _confluence: Confluence
1615
private _transport: Mail
1716
private _log: Logger
1817
private readonly _dryRun: boolean
18+
private _notificationBatch: { [author: string]: Array<DocumentInfo> } = {}
1919

2020
constructor(configuration: Configuration, smtpTransportUrl: string, confluence: Confluence, transport: Mail = null, dryRun = false) {
2121
this._configuration = configuration
@@ -26,43 +26,64 @@ export class Notification {
2626
this._dryRun = dryRun
2727
}
2828

29-
public async notify(documentInfo: DocumentInfo): Promise<void> {
30-
for (const exception of this._configuration.exceptions) {
31-
if (documentInfo.matchesPath(exception)) {
32-
this._log.info(`Skipping ${documentInfo.title} because it matches the exception ${exception}`)
33-
return
34-
}
35-
}
36-
29+
public async notify(documentInfos: Array<DocumentInfo>): Promise<void> {
3730
Handlebars.registerHelper('moment', (text, format) => {
3831
return moment(text).format(format)
3932
})
4033
const subjectTemplate = Handlebars.compile(this._configuration.notificationSubjectTemplate)
4134
const bodyTemplate = Handlebars.compile(this._configuration.notificationBodyTemplate)
4235

43-
documentInfo.lastVersionDate = (documentInfo.lastVersionDate as Moment).toISOString() as string
36+
for (const documentInfo of documentInfos) {
37+
for (const exception of this._configuration.exceptions) {
38+
if (documentInfo.matchesPath(exception)) {
39+
this._log.info(`Skipping ${documentInfo.title} because it matches the exception ${exception}`)
40+
return
41+
}
42+
}
4443

45-
for (const maintainer of this._configuration.maintainer) {
46-
if (documentInfo.matchesPath(maintainer.pagePattern)) {
47-
documentInfo.author = maintainer.maintainer.replace(/_lastauthor/, documentInfo.author)
44+
documentInfo.lastVersionDate = (documentInfo.lastVersionDate as Moment).toISOString() as string
45+
46+
for (const maintainer of this._configuration.maintainer) {
47+
if (documentInfo.matchesPath(maintainer.pagePattern)) {
48+
documentInfo.author = maintainer.maintainer.replace(/_lastauthor/, documentInfo.author)
49+
}
4850
}
49-
}
5051

51-
let to = documentInfo.author.split(/,/)
52-
if (this._configuration.domain) {
53-
to = to.map((target) => `${target}@${this._configuration.domain}`)
54-
}
52+
let to = documentInfo.author.split(/,/)
53+
if (this._configuration.domain) {
54+
to = to.map((target) => `${target}@${this._configuration.domain}`)
55+
}
56+
57+
for (const recipient of to) {
58+
if (!(recipient in this._notificationBatch)) {
59+
this._notificationBatch[recipient] = []
60+
}
5561

56-
const mailOptions = {
57-
from: this._configuration.notificationFrom,
58-
to: to.join(','),
59-
subject: subjectTemplate(documentInfo),
60-
html: bodyTemplate(documentInfo),
62+
this._notificationBatch[recipient].push(documentInfo)
63+
}
6164
}
62-
this._log.info(`Notifying ${mailOptions.to} about ${documentInfo.title}`)
63-
this._log.trace(mailOptions)
64-
if (!this._dryRun) {
65-
await this._transport.sendMail(mailOptions)
65+
66+
for (const recipient of Object.keys(this._notificationBatch)) {
67+
const mailOptions = {
68+
from: this._configuration.notificationFrom,
69+
to: recipient,
70+
subject: subjectTemplate({
71+
documentsCount: this._notificationBatch[recipient].length,
72+
multipleDocuments: this._notificationBatch[recipient].length > 1,
73+
documents: this._notificationBatch[recipient],
74+
}),
75+
html: bodyTemplate({
76+
documentsCount: this._notificationBatch[recipient].length,
77+
multipleDocuments: this._notificationBatch[recipient].length > 1,
78+
documents: this._notificationBatch[recipient],
79+
}),
80+
}
81+
82+
this._log.info(`Notifying ${mailOptions.to} about ${this._notificationBatch[recipient].length} document(s)`)
83+
this._log.trace(mailOptions)
84+
if (!this._dryRun) {
85+
await this._transport.sendMail(mailOptions)
86+
}
6687
}
6788
}
6889
}

lib/commands/Check.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ export default class extends Command {
6060
}
6161
const checkedDocuments = await confluence.findDocumentsOlderThan(filter, check.maxAge)
6262

63-
for (const checkedDocument of checkedDocuments) {
64-
await notification.notify(checkedDocument)
65-
}
63+
await notification.notify(checkedDocuments)
6664
}
6765
}
6866
}

resources/configurationDocument.html

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -97,21 +97,21 @@
9797
<ac:structured-macro ac:name="panel" ac:schema-version="1" ac:macro-id="f8503e48-c671-4ed6-897c-def2b2c3fa29">
9898
<ac:parameter ac:name="title">Subject</ac:parameter>
9999
<ac:rich-text-body>
100-
<p>Dokument outdated: {{title}}</p></ac:rich-text-body>
100+
<p>[CONFLUENCE-OUTDATED] {{ documentsCount }} document{{#if multipleDocuments}}s{{/if}} outdated</p></ac:rich-text-body>
101101
</ac:structured-macro>
102102
<ac:structured-macro ac:name="panel" ac:schema-version="1" ac:macro-id="63c16112-dea3-434e-b1cb-467ff4e36d5f">
103103
<ac:parameter ac:name="title">Body</ac:parameter>
104104
<ac:rich-text-body>
105105
<p>Greetings {{author}},</p>
106-
<p>The most recent change of the document</p>
107-
<p>{{title}}</p>
108-
<p>was at {{lastVersionDate}}.</p>
109-
<p>{{#if lastVersionMessage}}</p>
110-
<p>The edit comment used was: {{lastVersionMessage}}</p>
111-
<p>{{/if}}</p>
112-
<p>You can find the document here: {{url}}</p>
106+
<p>The following document{{#if multipleDocuments}}s{{/if}} are outdated:</p>
107+
<ul>
108+
{{#each documents}}
109+
<li><a href="{{url}}">{{title}}</a> ({{lastVersionDate}}) ({{lastVersionMessage}})</li>
110+
{{/each}}
111+
</ul>
113112
<p>Please check, wether the document needs any updates or save the document again stating that it is still valid.</p>
114-
<p>Cheers, confluence-outdated</p></ac:rich-text-body>
113+
<p>Cheers, confluence-outdated</p>
114+
</ac:rich-text-body>
115115
</ac:structured-macro>
116116
</ac:rich-text-body>
117117
</ac:structured-macro>

test/MockServer.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,16 @@ import nock = require('nock')
33
export class MockServer {
44
private _scope: nock.Scope
55

6-
public static readonly NOTIFICATION_SUBJECT = 'Document outdated: {{title}}'
7-
public static readonly NOTIFICATION_BODY = `<p>Hello {{author}}!</p>
8-
<p><br></p>
9-
<p>The document</p>
10-
<p>{{title}}</p>
11-
<p><br></p>
12-
<p>was last changed at {{lastVersionDate}}.</p>
13-
<p>{{#if lastVersionMessage}}</p>
14-
<p>The comment for the change was: {{lastVersionMessage}}</p>
15-
<p>{{/if}}</p>
16-
<p><br></p>
17-
<p>You can find the document here: {{url}}</p>
6+
public static readonly NOTIFICATION_SUBJECT = `[CONFLUENCE-OUTDATED] {{ documentsCount }} document{{#if multipleDocuments}}s{{/if}} outdated`
7+
public static readonly NOTIFICATION_BODY = `<p>Greetings {{author}},</p>
8+
<p>The following document{{#if multipleDocuments}}s{{/if}} are outdated:</p>
9+
<ul>
10+
{{#each documents}}
11+
<li><a href="{{url}}">{{title}}</a> ({{lastVersionDate}}) ({{lastVersionMessage}})</li>
12+
{{/each}}
13+
</ul>
14+
<p>Please check, wether the document needs any updates or save the document again stating that it is still valid.</p>
15+
<p>Cheers, confluence-outdated</p>
1816
`
1917

2018
constructor(basePath: string) {

test/NotificationTest.ts

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,41 +33,97 @@ describe('The Notification API', (): void => {
3333
it('should send notifications', async (): Promise<void> => {
3434
const notification = new Notification(configuration, '', confluence, transportStub)
3535
const documentInfo = new DocumentInfo(0, 'author', moment(), 'message', 'title', ['main'], 'http://example.com')
36-
await notification.notify(documentInfo)
36+
await notification.notify([documentInfo])
3737
chai.expect((transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledOnce).to.be.true
3838
chai.expect(
3939
(transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledWith({
4040
from: 'Notification <noreply@example.com>',
4141
to: 'author@example.com',
42-
subject: Handlebars.compile(MockServer.NOTIFICATION_SUBJECT)(documentInfo),
43-
html: Handlebars.compile(MockServer.NOTIFICATION_BODY)(documentInfo),
42+
subject: Handlebars.compile(MockServer.NOTIFICATION_SUBJECT)({
43+
documentsCount: 1,
44+
documents: [documentInfo],
45+
multipleDocuments: false,
46+
}),
47+
html: Handlebars.compile(MockServer.NOTIFICATION_BODY)({
48+
documentsCount: 1,
49+
documents: [documentInfo],
50+
multipleDocuments: false,
51+
}),
4452
})
4553
).to.be.true
4654
})
4755
it('should use a maintainer when configured', async (): Promise<void> => {
4856
const notification = new Notification(configuration, '', confluence, transportStub)
4957
const documentInfo = new DocumentInfo(0, 'author2', moment(), 'message', 'Test2', ['main', 'Test'], 'http://example.com')
50-
await notification.notify(documentInfo)
51-
chai.expect((transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledOnce).to.be.true
58+
await notification.notify([documentInfo])
59+
chai.expect((transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledTwice).to.be.true
60+
chai.expect(
61+
(transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledWith({
62+
from: 'Notification <noreply@example.com>',
63+
to: 'maintainer@example.com',
64+
subject: Handlebars.compile(MockServer.NOTIFICATION_SUBJECT)({
65+
documentsCount: 1,
66+
documents: [documentInfo],
67+
multipleDocuments: false,
68+
}),
69+
html: Handlebars.compile(MockServer.NOTIFICATION_BODY)({
70+
documentsCount: 1,
71+
documents: [documentInfo],
72+
multipleDocuments: false,
73+
}),
74+
})
75+
).to.be.true
5276
chai.expect(
5377
(transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledWith({
5478
from: 'Notification <noreply@example.com>',
55-
to: 'maintainer@example.com,author2@example.com',
56-
subject: Handlebars.compile(MockServer.NOTIFICATION_SUBJECT)(documentInfo),
57-
html: Handlebars.compile(MockServer.NOTIFICATION_BODY)(documentInfo),
79+
to: 'author2@example.com',
80+
subject: Handlebars.compile(MockServer.NOTIFICATION_SUBJECT)({
81+
documentsCount: 1,
82+
documents: [documentInfo],
83+
multipleDocuments: false,
84+
}),
85+
html: Handlebars.compile(MockServer.NOTIFICATION_BODY)({
86+
documentsCount: 1,
87+
documents: [documentInfo],
88+
multipleDocuments: false,
89+
}),
5890
})
5991
).to.be.true
6092
})
6193
it('should not send notifications on a dry run', async (): Promise<void> => {
6294
const notification = new Notification(configuration, '', confluence, transportStub, true)
6395
const documentInfo = new DocumentInfo(0, 'author', moment(), 'message', 'title', ['main'], 'http://example.com')
64-
await notification.notify(documentInfo)
96+
await notification.notify([documentInfo])
6597
chai.expect((transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.notCalled).to.be.true
6698
})
6799
it('should not send notifications for an excluded document', async (): Promise<void> => {
68100
const notification = new Notification(configuration, '', confluence, transportStub)
69101
const documentInfo = new DocumentInfo(0, 'author2', moment(), 'message', 'NOT', ['main', 'Test'], 'http://example.com')
70-
await notification.notify(documentInfo)
102+
await notification.notify([documentInfo])
71103
chai.expect((transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledOnce).to.be.false
72104
})
105+
106+
it('should send notifications in a batch if configured', async (): Promise<void> => {
107+
const notification = new Notification(configuration, '', confluence, transportStub)
108+
const documentInfo = new DocumentInfo(0, 'author', moment(), 'message', 'title1', ['main'], 'http://example.com')
109+
const documentInfo2 = new DocumentInfo(0, 'author', moment(), 'message', 'title2', ['main'], 'http://example.com')
110+
await notification.notify([documentInfo, documentInfo2])
111+
chai.expect((transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledOnce).to.be.true
112+
chai.expect(
113+
(transportStub as unknown as SinonStubbedInstance<Mail>).sendMail.calledWith({
114+
from: 'Notification <noreply@example.com>',
115+
to: 'author@example.com',
116+
subject: Handlebars.compile(MockServer.NOTIFICATION_SUBJECT)({
117+
documentsCount: 2,
118+
documents: [documentInfo, documentInfo2],
119+
multipleDocuments: true,
120+
}),
121+
html: Handlebars.compile(MockServer.NOTIFICATION_BODY)({
122+
documentsCount: 2,
123+
documents: [documentInfo, documentInfo2],
124+
multipleDocuments: true,
125+
}),
126+
})
127+
).to.be.true
128+
})
73129
})

0 commit comments

Comments
 (0)