1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 | import {
|
18 | addressSlackUsers,
|
19 | guid,
|
20 | MessageOptions,
|
21 | } from "@atomist/automation-client";
|
22 | import { Destination } from "@atomist/automation-client/lib/spi/message/MessageClient";
|
23 | import {
|
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";
|
37 | import {
|
38 | bold,
|
39 | channel,
|
40 | codeLine,
|
41 | escape,
|
42 | italic,
|
43 | SlackMessage,
|
44 | url,
|
45 | } from "@atomist/slack-messages";
|
46 | import * as _ from "lodash";
|
47 | import { CoreRepoFieldsAndChannels } from "../../typings/types";
|
48 | import { toArray } from "../../util/misc/array";
|
49 | import { updateGoalStateCommand } from "../goal-state/updateGoal";
|
50 |
|
51 |
|
52 |
|
53 |
|
54 | export type DestinationFactory = (goal: SdmGoalEvent, context: SdmContext) => Promise<Destination | Destination[] | undefined>;
|
55 |
|
56 |
|
57 |
|
58 |
|
59 | export type NotificationFactory = (gi: GoalCompletionListenerInvocation) => Promise<{ message: any, options: MessageOptions }>;
|
60 |
|
61 |
|
62 |
|
63 |
|
64 | export interface NotificationOptions {
|
65 | destination?: DestinationFactory | DestinationFactory[];
|
66 | notification?: NotificationFactory;
|
67 | }
|
68 |
|
69 |
|
70 |
|
71 |
|
72 |
|
73 |
|
74 |
|
75 | export 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 |
|
97 |
|
98 | export 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 |
|
112 |
|
113 | export 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 |
|
194 |
|
195 |
|
196 | export 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 |
|
218 | export 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 | }
|