UNPKG

9.12 kBPlain TextView Raw
1/*
2 * Copyright © 2019 Atomist, Inc.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {
18 addressSlackUsers,
19 guid,
20 MessageOptions,
21} from "@atomist/automation-client";
22import { Destination } from "@atomist/automation-client/lib/spi/message/MessageClient";
23import {
24 actionableButton,
25 CommandHandlerRegistration,
26 ExtensionPack,
27 GoalCompletionListener,
28 GoalCompletionListenerInvocation,
29 metadata,
30 SdmContext,
31 SdmGoalEvent,
32 SdmGoalState,
33 slackErrorMessage,
34 slackFooter,
35 slackInfoMessage,
36} from "@atomist/sdm";
37import {
38 bold,
39 channel,
40 codeLine,
41 escape,
42 italic,
43 SlackMessage,
44 url,
45} from "@atomist/slack-messages";
46import * as _ from "lodash";
47import { CoreRepoFieldsAndChannels } from "../../typings/types";
48import { toArray } from "../../util/misc/array";
49import { updateGoalStateCommand } from "../goal-state/updateGoal";
50
51/**
52 * Factory to create message destinations for goal notifications
53 */
54export type DestinationFactory = (goal: SdmGoalEvent, context: SdmContext) => Promise<Destination | Destination[] | undefined>;
55
56/**
57 * Factory to create notification messages
58 */
59export type NotificationFactory = (gi: GoalCompletionListenerInvocation) => Promise<{ message: any, options: MessageOptions }>;
60
61/**
62 * Options to configure the notification support
63 */
64export interface NotificationOptions {
65 destination?: DestinationFactory | DestinationFactory[];
66 notification?: NotificationFactory;
67}
68
69/**
70 * Extension pack to send notifications on certain conditions.
71 * Recipients and notification messages can be customized by providing options
72 * with DestinationFactory and NotificationFactory.
73 * @param options
74 */
75export function notificationSupport(options: NotificationOptions = {}): ExtensionPack {
76 return {
77 ...metadata("notification"),
78 configure: sdm => {
79
80 const updateGoalCommand = updateGoalStateCommand();
81 updateGoalCommand.name = `${updateGoalCommand.name}ForNotifications`;
82 sdm.addCommand(updateGoalCommand);
83
84 const optsToUse: NotificationOptions = {
85 destination: defaultDestinationFactory,
86 notification: defaultNotificationFactory(updateGoalCommand),
87 ...options,
88 };
89
90 sdm.addGoalCompletionListener(notifyGoalCompletionListener(optsToUse));
91 },
92 };
93}
94
95/**
96 * Default DestinationFactory that would send every commit author a direct message in Slack / MS Teams.
97 */
98export async function defaultDestinationFactory(goal: SdmGoalEvent): Promise<Destination | Destination[] | undefined> {
99 if (goal.state === SdmGoalState.failure) {
100
101 return _.uniqBy(goal.push.commits.map(c => _.get(c, "author.person.chatId"))
102 .filter(c => !!c), r => `${r.chatTeam.id}.${r.screenName}`)
103 .map(r => addressSlackUsers(r.chatTeam.id, r.screenName));
104
105 }
106
107 return undefined;
108}
109
110/**
111 * Default NotificationFactory that sends messages with restart, start and approve buttons where appropriate.
112 */
113export function defaultNotificationFactory(updateGoalCommand: CommandHandlerRegistration<any>): NotificationFactory {
114 return async gi => {
115 const { completedGoal, context } = gi;
116 const goalSetId = completedGoal.goalSetId;
117 const uniqueName = completedGoal.uniqueName;
118 const msgId = guid();
119
120 let state: string;
121 let suffix: string;
122 let msg: SlackMessage;
123 switch (completedGoal.state) {
124 case SdmGoalState.failure:
125 state = "has failed";
126 suffix = "Failed";
127 msg = slackErrorMessage("", "", context, {
128 actions: completedGoal.retryFeasible ? [
129 actionableButton({ text: "Restart" }, updateGoalCommand, {
130 goalSetId,
131 uniqueName,
132 msgId,
133 state: SdmGoalState.requested,
134 })] : [],
135 });
136 break;
137 case SdmGoalState.waiting_for_approval:
138 state = "is waiting for approval";
139 suffix = "Awaiting Approval";
140 msg = slackInfoMessage("", "", {
141 actions: [actionableButton({ text: "Approve" }, updateGoalCommand, {
142 goalSetId,
143 uniqueName,
144 msgId,
145 state: SdmGoalState.approved,
146 })],
147 });
148 break;
149 case SdmGoalState.waiting_for_pre_approval:
150 state = "is waiting to start";
151 suffix = "Awaiting Start";
152 msg = slackInfoMessage("", "", {
153 actions: [actionableButton({ text: "Start" }, updateGoalCommand, {
154 goalSetId,
155 uniqueName,
156 msgId,
157 state: SdmGoalState.pre_approved,
158 })],
159 });
160 break;
161 case SdmGoalState.stopped:
162 state = "has stopped";
163 suffix = "Stopped";
164 msg = slackInfoMessage("", "");
165 break;
166 default:
167 return undefined;
168 }
169
170 const author = `Goal ${suffix}`;
171 const commitMsg = truncateCommitMessage(completedGoal.push.after.message);
172 const text = `Goal ${italic(completedGoal.url ? url(completedGoal.url, completedGoal.name) : completedGoal.name)} on ${
173 url(completedGoal.push.after.url, codeLine(completedGoal.sha.slice(0, 7)))} ${italic(commitMsg)} of ${
174 bold(`${url(completedGoal.push.repo.url, `${completedGoal.repo.owner}/${completedGoal.repo.name}/${
175 completedGoal.branch}`)}`)} ${state}.`;
176 const channels: CoreRepoFieldsAndChannels.Channels[] = _.get(completedGoal, "push.repo.channels") || [];
177 const channelLink = channels.filter(c => !!c.channelId).map(c => channel(c.channelId)).join(" \u00B7 ");
178 const link =
179 `https://app.atomist.com/workspace/${context.workspaceId}/goalset/${completedGoal.goalSetId}`;
180
181 msg.attachments[0] = {
182 ...msg.attachments[0],
183 author_name: author,
184 text,
185 footer: `${slackFooter()} \u00B7 ${url(link, completedGoal.goalSetId.slice(0, 7))} \u00B7 ${channelLink}`,
186 };
187
188 return { message: msg, options: { id: msgId } };
189 };
190}
191
192/**
193 * GoalCompletionListener that delegates to the NotificationFactory and DestinationFactory to
194 * create notifications and send them out.
195 */
196export function notifyGoalCompletionListener(options: NotificationOptions): GoalCompletionListener {
197 return async gi => {
198 const { completedGoal, context } = gi;
199
200 const destinations: Destination[] = [];
201
202 for (const destinationFactory of toArray(options.destination || [])) {
203 const newDestinations = await destinationFactory(completedGoal, gi);
204 if (!!newDestinations) {
205 destinations.push(...toArray(newDestinations));
206 }
207 }
208
209 if (destinations.length > 0) {
210 const msg = await options.notification(gi);
211 for (const destination of destinations) {
212 await context.messageClient.send(msg.message, destination, msg.options);
213 }
214 }
215 };
216}
217
218export function truncateCommitMessage(message: string): string {
219 const title = (message || "").split("\n")[0];
220 const escapedTitle = escape(title);
221
222 if (escapedTitle.length <= 50) {
223 return escapedTitle;
224 }
225
226 const splitRegExp = /(&(?:[gl]t|amp);|<.*?\||>)/;
227 const titleParts = escapedTitle.split(splitRegExp);
228 let truncatedTitle = "";
229 let addNext = 1;
230 let i;
231 for (i = 0; i < titleParts.length; i++) {
232 let newTitle = truncatedTitle;
233 if (i % 2 === 0) {
234 newTitle += titleParts[i];
235 } else if (/^&(?:[gl]t|amp);$/.test(titleParts[i])) {
236 newTitle += "&";
237 } else if (/^<.*\|$/.test(titleParts[i])) {
238 addNext = 2;
239 continue;
240 } else if (titleParts[i] === ">") {
241 addNext = 1;
242 continue;
243 }
244 if (newTitle.length > 50) {
245 const l = 50 - newTitle.length;
246 titleParts[i] = titleParts[i].slice(0, l) + "...";
247 break;
248 }
249 truncatedTitle = newTitle;
250 }
251 return titleParts.slice(0, i + addNext).join("");
252}