@atomist/sdm-core
Version:
Atomist Software Delivery Machine - Implementation
264 lines (240 loc) • 8.91 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 * as slack from "@atomist/slack-messages";
import * as _ from "lodash";
// This file copied from atomist/lifecycle-automation
/**
* Safely truncate the first line of a commit message to 50 characters
* or less. Only count printable characters, i.e., not link URLs or
* markup.
*/
export function truncateCommitMessage(message: string, repo: any): string {
const title = message.split("\n")[0];
const escapedTitle = slack.escape(title);
const linkedTitle = linkIssues(escapedTitle, repo);
if (linkedTitle.length <= 50) {
return linkedTitle;
}
const splitRegExp = /(&(?:[gl]t|amp);|<.*?\||>)/;
const titleParts = linkedTitle.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("");
}
/**
* Generate GitHub repository "slug", i.e., owner/repo.
*
* @param repo repository with .owner and .name
* @return owner/name string
*/
export function repoSlug(repo: RepoInfo): string {
return `${repo.owner}/${repo.name}`;
}
export function htmlUrl(repo: RepoInfo): string {
if (repo.org && repo.org.provider && repo.org.provider.url) {
let providerUrl = repo.org.provider.url;
if (providerUrl.slice(-1) === "/") {
providerUrl = providerUrl.slice(0, -1);
}
return providerUrl;
} else {
return "https://github.com";
}
}
export const DefaultGitHubApiUrl = "https://api.github.com/";
export function apiUrl(repo: any): string {
if (repo.org && repo.org.provider && repo.org.provider.url) {
let providerUrl = repo.org.provider.apiUrl;
if (providerUrl.slice(-1) === "/") {
providerUrl = providerUrl.slice(0, -1);
}
return providerUrl;
} else {
return DefaultGitHubApiUrl;
}
}
export function userUrl(repo: any, login: string): string {
return `${htmlUrl(repo)}/${login}`;
}
export interface RepoInfo {
owner: string;
name: string;
org?: {
provider: { url?: string },
};
}
export function avatarUrl(repo: any, login: string): string {
if (repo.org !== undefined && repo.org.provider !== undefined && repo.org.provider.url !== undefined) {
return `${htmlUrl(repo)}/avatars/${login}`;
} else {
return `https://avatars.githubusercontent.com/${login}`;
}
}
export function commitUrl(repo: RepoInfo, commit: any): string {
return `${htmlUrl(repo)}/${repoSlug(repo)}/commit/${commit.sha}`;
}
/**
* If the URL is of an image, return a Slack message attachment that
* will render that image. Otherwise return null.
*
* @param url full URL
* @return Slack message attachment for image or null
*/
function urlToImageAttachment(url: string): slack.Attachment {
const imageRegExp = /[^\/]+\.(?:png|jpe?g|gif|bmp)$/i;
const imageMatch = imageRegExp.exec(url);
if (imageMatch) {
const image = imageMatch[0];
return {
text: image,
image_url: url,
fallback: image,
};
} else {
return undefined;
}
}
/**
* Find image URLs in a message body, returning an array of Slack
* message attachments, one for each image. It expects the message to
* be in Slack message markup.
*
* @param body message body
* @return array of Slack message Attachments with the `image_url` set
* to the URL of the image and the `text` and `fallback` set
* to the image name.
*/
export function extractImageUrls(body: string): slack.Attachment[] {
const slackLinkRegExp = /<(https?:\/\/.*?)(?:\|.*?)?>/g;
// inspired by https://stackoverflow.com/a/6927878/5464956
const urlRegExp = /\bhttps?:\/\/[^\s<>\[\]]+[^\s`!()\[\]{};:'".,<>?«»“”‘’]/gi;
const attachments: slack.Attachment[] = [];
const bodyParts = body.split(slackLinkRegExp);
for (let i = 0; i < bodyParts.length; i++) {
if (i % 2 === 0) {
let match: RegExpExecArray;
// tslint:disable-next-line:no-conditional-assignment
while (match = urlRegExp.exec(bodyParts[i])) {
const url = match[0];
const attachment = urlToImageAttachment(url);
if (attachment) {
attachments.push(attachment);
}
}
} else {
const url = bodyParts[i];
const attachment = urlToImageAttachment(url);
if (attachment) {
attachments.push(attachment);
}
}
}
const uniqueAttachments: slack.Attachment[] = [];
attachments.forEach(a => {
if (!uniqueAttachments.some(ua => ua.image_url === a.image_url)) {
uniqueAttachments.push(a);
}
});
return uniqueAttachments;
}
/**
* Find issue mentions in body and replace them with links.
*
* @param body message to modify
* @param repo repository information
* @return string with issue mentions replaced with links
*/
export function linkIssues(body: string, repo: any): string {
if (!body || body.length === 0) {
return body;
}
const splitter = /(\[.+?\](?:\[.*?\]|\(.+?\)|:\s*http.*)|^```.*\n[\S\s]*?^```\s*\n|<.+?>)/m;
const bodyParts = body.split(splitter);
const baseUrl = htmlUrl(repo);
for (let j = 0; j < bodyParts.length; j += 2) {
let newPart = bodyParts[j];
const allIssueMentions = getIssueMentions(newPart);
allIssueMentions.forEach(i => {
const iMatchPrefix = (i.indexOf("#") === 0) ? `^|\\W` : repoIssueMatchPrefix;
const iRegExp = new RegExp(`(${iMatchPrefix})${i}(?!\\w)`, "g");
const iSlug = (i.indexOf("#") === 0) ? `${repo.owner}/${repo.name}${i}` : i;
const iUrlPath = iSlug.replace("#", "/issues/");
const iLink = slack.url(`${baseUrl}/${iUrlPath}`, i);
newPart = newPart.replace(iRegExp, `\$1${iLink}`);
});
bodyParts[j] = newPart;
}
return bodyParts.join("");
}
const gitHubUserMatch = "[a-zA-Z\\d]+(?:-[a-zA-Z\\d]+)*";
/**
* Regular expression to find issue mentions. There are capture
* groups for the issue repository owner, repository name, and issue
* number. The capture groups for repository owner and name are
* optional and therefore may be null, although if one is set, the
* other should be as well.
*
* The rules for preceding characters is different for current repo
* matches, e.g., "#43", and other repo matches, e.g., "some/repo#44".
* Current repo matches allow anything but word characters to precede
* them. Other repo matches only allow a few other characters to
* preceed them.
*/
const repoIssueMatchPrefix = "^|[[\\s:({]";
// tslint:disable-next-line:max-line-length
const issueMentionMatch = `(?:^|(?:${repoIssueMatchPrefix})(${gitHubUserMatch})\/(${gitHubUserMatch})|\\W)#([1-9]\\d*)(?!\\w)`;
const issueMentionRegExp = new RegExp(issueMentionMatch, "g");
/**
* Find all issue mentions and return an array of unique issue
* mentions as "#3" and "owner/repo#5".
*
* @param msg string that may contain mentions
* @return unique list of issue mentions as #N or O/R#N
*/
export function getIssueMentions(msg: string = ""): string[] {
const allMentions: string[] = [];
let matches: string[];
// tslint:disable-next-line:no-conditional-assignment
while (matches = issueMentionRegExp.exec(msg)) {
const owner = matches[1];
const repo = matches[2];
const issue = matches[3];
const slug = (owner && repo) ? `${owner}/${repo}` : "";
allMentions.push(`${slug}#${issue}`);
}
return _.uniq(allMentions);
}