@atomist/sdm-core
Version:
Atomist Software Delivery Machine - Implementation
253 lines (228 loc) • 9.12 kB
text/typescript
/*
* Copyright © 2019 Atomist, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
addressSlackUsers,
guid,
MessageOptions,
} from "@atomist/automation-client";
import { Destination } from "@atomist/automation-client/lib/spi/message/MessageClient";
import {
actionableButton,
CommandHandlerRegistration,
ExtensionPack,
GoalCompletionListener,
GoalCompletionListenerInvocation,
metadata,
SdmContext,
SdmGoalEvent,
SdmGoalState,
slackErrorMessage,
slackFooter,
slackInfoMessage,
} from "@atomist/sdm";
import {
bold,
channel,
codeLine,
escape,
italic,
SlackMessage,
url,
} from "@atomist/slack-messages";
import * as _ from "lodash";
import { CoreRepoFieldsAndChannels } from "../../typings/types";
import { toArray } from "../../util/misc/array";
import { updateGoalStateCommand } from "../goal-state/updateGoal";
/**
* Factory to create message destinations for goal notifications
*/
export type DestinationFactory = (goal: SdmGoalEvent, context: SdmContext) => Promise<Destination | Destination[] | undefined>;
/**
* Factory to create notification messages
*/
export type NotificationFactory = (gi: GoalCompletionListenerInvocation) => Promise<{ message: any, options: MessageOptions }>;
/**
* Options to configure the notification support
*/
export interface NotificationOptions {
destination?: DestinationFactory | DestinationFactory[];
notification?: NotificationFactory;
}
/**
* Extension pack to send notifications on certain conditions.
* Recipients and notification messages can be customized by providing options
* with DestinationFactory and NotificationFactory.
* @param options
*/
export function notificationSupport(options: NotificationOptions = {}): ExtensionPack {
return {
...metadata("notification"),
configure: sdm => {
const updateGoalCommand = updateGoalStateCommand();
updateGoalCommand.name = `${updateGoalCommand.name}ForNotifications`;
sdm.addCommand(updateGoalCommand);
const optsToUse: NotificationOptions = {
destination: defaultDestinationFactory,
notification: defaultNotificationFactory(updateGoalCommand),
...options,
};
sdm.addGoalCompletionListener(notifyGoalCompletionListener(optsToUse));
},
};
}
/**
* Default DestinationFactory that would send every commit author a direct message in Slack / MS Teams.
*/
export async function defaultDestinationFactory(goal: SdmGoalEvent): Promise<Destination | Destination[] | undefined> {
if (goal.state === SdmGoalState.failure) {
return _.uniqBy(goal.push.commits.map(c => _.get(c, "author.person.chatId"))
.filter(c => !!c), r => `${r.chatTeam.id}.${r.screenName}`)
.map(r => addressSlackUsers(r.chatTeam.id, r.screenName));
}
return undefined;
}
/**
* Default NotificationFactory that sends messages with restart, start and approve buttons where appropriate.
*/
export function defaultNotificationFactory(updateGoalCommand: CommandHandlerRegistration<any>): NotificationFactory {
return async gi => {
const { completedGoal, context } = gi;
const goalSetId = completedGoal.goalSetId;
const uniqueName = completedGoal.uniqueName;
const msgId = guid();
let state: string;
let suffix: string;
let msg: SlackMessage;
switch (completedGoal.state) {
case SdmGoalState.failure:
state = "has failed";
suffix = "Failed";
msg = slackErrorMessage("", "", context, {
actions: completedGoal.retryFeasible ? [
actionableButton({ text: "Restart" }, updateGoalCommand, {
goalSetId,
uniqueName,
msgId,
state: SdmGoalState.requested,
})] : [],
});
break;
case SdmGoalState.waiting_for_approval:
state = "is waiting for approval";
suffix = "Awaiting Approval";
msg = slackInfoMessage("", "", {
actions: [actionableButton({ text: "Approve" }, updateGoalCommand, {
goalSetId,
uniqueName,
msgId,
state: SdmGoalState.approved,
})],
});
break;
case SdmGoalState.waiting_for_pre_approval:
state = "is waiting to start";
suffix = "Awaiting Start";
msg = slackInfoMessage("", "", {
actions: [actionableButton({ text: "Start" }, updateGoalCommand, {
goalSetId,
uniqueName,
msgId,
state: SdmGoalState.pre_approved,
})],
});
break;
case SdmGoalState.stopped:
state = "has stopped";
suffix = "Stopped";
msg = slackInfoMessage("", "");
break;
default:
return undefined;
}
const author = `Goal ${suffix}`;
const commitMsg = truncateCommitMessage(completedGoal.push.after.message);
const text = `Goal ${italic(completedGoal.url ? url(completedGoal.url, completedGoal.name) : completedGoal.name)} on ${
url(completedGoal.push.after.url, codeLine(completedGoal.sha.slice(0, 7)))} ${italic(commitMsg)} of ${
bold(`${url(completedGoal.push.repo.url, `${completedGoal.repo.owner}/${completedGoal.repo.name}/${
completedGoal.branch}`)}`)} ${state}.`;
const channels: CoreRepoFieldsAndChannels.Channels[] = _.get(completedGoal, "push.repo.channels") || [];
const channelLink = channels.filter(c => !!c.channelId).map(c => channel(c.channelId)).join(" \u00B7 ");
const link =
`https://app.atomist.com/workspace/${context.workspaceId}/goalset/${completedGoal.goalSetId}`;
msg.attachments[0] = {
...msg.attachments[0],
author_name: author,
text,
footer: `${slackFooter()} \u00B7 ${url(link, completedGoal.goalSetId.slice(0, 7))} \u00B7 ${channelLink}`,
};
return { message: msg, options: { id: msgId } };
};
}
/**
* GoalCompletionListener that delegates to the NotificationFactory and DestinationFactory to
* create notifications and send them out.
*/
export function notifyGoalCompletionListener(options: NotificationOptions): GoalCompletionListener {
return async gi => {
const { completedGoal, context } = gi;
const destinations: Destination[] = [];
for (const destinationFactory of toArray(options.destination || [])) {
const newDestinations = await destinationFactory(completedGoal, gi);
if (!!newDestinations) {
destinations.push(...toArray(newDestinations));
}
}
if (destinations.length > 0) {
const msg = await options.notification(gi);
for (const destination of destinations) {
await context.messageClient.send(msg.message, destination, msg.options);
}
}
};
}
export function truncateCommitMessage(message: string): string {
const title = (message || "").split("\n")[0];
const escapedTitle = escape(title);
if (escapedTitle.length <= 50) {
return escapedTitle;
}
const splitRegExp = /(&(?:[gl]t|amp);|<.*?\||>)/;
const titleParts = escapedTitle.split(splitRegExp);
let truncatedTitle = "";
let addNext = 1;
let i;
for (i = 0; i < titleParts.length; i++) {
let newTitle = truncatedTitle;
if (i % 2 === 0) {
newTitle += titleParts[i];
} else if (/^&(?:[gl]t|amp);$/.test(titleParts[i])) {
newTitle += "&";
} else if (/^<.*\|$/.test(titleParts[i])) {
addNext = 2;
continue;
} else if (titleParts[i] === ">") {
addNext = 1;
continue;
}
if (newTitle.length > 50) {
const l = 50 - newTitle.length;
titleParts[i] = titleParts[i].slice(0, l) + "...";
break;
}
truncatedTitle = newTitle;
}
return titleParts.slice(0, i + addNext).join("");
}