UNPKG

8.91 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 * as slack from "@atomist/slack-messages";
18import * as _ from "lodash";
19
20// This file copied from atomist/lifecycle-automation
21
22/**
23 * Safely truncate the first line of a commit message to 50 characters
24 * or less. Only count printable characters, i.e., not link URLs or
25 * markup.
26 */
27export function truncateCommitMessage(message: string, repo: any): string {
28 const title = message.split("\n")[0];
29 const escapedTitle = slack.escape(title);
30 const linkedTitle = linkIssues(escapedTitle, repo);
31
32 if (linkedTitle.length <= 50) {
33 return linkedTitle;
34 }
35
36 const splitRegExp = /(&(?:[gl]t|amp);|<.*?\||>)/;
37 const titleParts = linkedTitle.split(splitRegExp);
38 let truncatedTitle = "";
39 let addNext = 1;
40 let i;
41 for (i = 0; i < titleParts.length; i++) {
42 let newTitle = truncatedTitle;
43 if (i % 2 === 0) {
44 newTitle += titleParts[i];
45 } else if (/^&(?:[gl]t|amp);$/.test(titleParts[i])) {
46 newTitle += "&";
47 } else if (/^<.*\|$/.test(titleParts[i])) {
48 addNext = 2;
49 continue;
50 } else if (titleParts[i] === ">") {
51 addNext = 1;
52 continue;
53 }
54 if (newTitle.length > 50) {
55 const l = 50 - newTitle.length;
56 titleParts[i] = titleParts[i].slice(0, l) + "...";
57 break;
58 }
59 truncatedTitle = newTitle;
60 }
61 return titleParts.slice(0, i + addNext).join("");
62}
63
64/**
65 * Generate GitHub repository "slug", i.e., owner/repo.
66 *
67 * @param repo repository with .owner and .name
68 * @return owner/name string
69 */
70export function repoSlug(repo: RepoInfo): string {
71 return `${repo.owner}/${repo.name}`;
72}
73
74export function htmlUrl(repo: RepoInfo): string {
75 if (repo.org && repo.org.provider && repo.org.provider.url) {
76 let providerUrl = repo.org.provider.url;
77 if (providerUrl.slice(-1) === "/") {
78 providerUrl = providerUrl.slice(0, -1);
79 }
80 return providerUrl;
81 } else {
82 return "https://github.com";
83 }
84}
85
86export const DefaultGitHubApiUrl = "https://api.github.com/";
87
88export function apiUrl(repo: any): string {
89 if (repo.org && repo.org.provider && repo.org.provider.url) {
90 let providerUrl = repo.org.provider.apiUrl;
91 if (providerUrl.slice(-1) === "/") {
92 providerUrl = providerUrl.slice(0, -1);
93 }
94 return providerUrl;
95 } else {
96 return DefaultGitHubApiUrl;
97 }
98}
99
100export function userUrl(repo: any, login: string): string {
101 return `${htmlUrl(repo)}/${login}`;
102}
103
104export interface RepoInfo {
105 owner: string;
106 name: string;
107 org?: {
108 provider: { url?: string },
109 };
110}
111
112export function avatarUrl(repo: any, login: string): string {
113 if (repo.org !== undefined && repo.org.provider !== undefined && repo.org.provider.url !== undefined) {
114 return `${htmlUrl(repo)}/avatars/${login}`;
115 } else {
116 return `https://avatars.githubusercontent.com/${login}`;
117 }
118}
119
120export function commitUrl(repo: RepoInfo, commit: any): string {
121 return `${htmlUrl(repo)}/${repoSlug(repo)}/commit/${commit.sha}`;
122}
123
124/**
125 * If the URL is of an image, return a Slack message attachment that
126 * will render that image. Otherwise return null.
127 *
128 * @param url full URL
129 * @return Slack message attachment for image or null
130 */
131function urlToImageAttachment(url: string): slack.Attachment {
132 const imageRegExp = /[^\/]+\.(?:png|jpe?g|gif|bmp)$/i;
133 const imageMatch = imageRegExp.exec(url);
134 if (imageMatch) {
135 const image = imageMatch[0];
136 return {
137 text: image,
138 image_url: url,
139 fallback: image,
140 };
141 } else {
142 return undefined;
143 }
144}
145
146/**
147 * Find image URLs in a message body, returning an array of Slack
148 * message attachments, one for each image. It expects the message to
149 * be in Slack message markup.
150 *
151 * @param body message body
152 * @return array of Slack message Attachments with the `image_url` set
153 * to the URL of the image and the `text` and `fallback` set
154 * to the image name.
155 */
156export function extractImageUrls(body: string): slack.Attachment[] {
157 const slackLinkRegExp = /<(https?:\/\/.*?)(?:\|.*?)?>/g;
158 // inspired by https://stackoverflow.com/a/6927878/5464956
159 const urlRegExp = /\bhttps?:\/\/[^\s<>\[\]]+[^\s`!()\[\]{};:'".,<>?«»“”‘’]/gi;
160 const attachments: slack.Attachment[] = [];
161 const bodyParts = body.split(slackLinkRegExp);
162 for (let i = 0; i < bodyParts.length; i++) {
163 if (i % 2 === 0) {
164 let match: RegExpExecArray;
165 // tslint:disable-next-line:no-conditional-assignment
166 while (match = urlRegExp.exec(bodyParts[i])) {
167 const url = match[0];
168 const attachment = urlToImageAttachment(url);
169 if (attachment) {
170 attachments.push(attachment);
171 }
172 }
173 } else {
174 const url = bodyParts[i];
175 const attachment = urlToImageAttachment(url);
176 if (attachment) {
177 attachments.push(attachment);
178 }
179 }
180 }
181 const uniqueAttachments: slack.Attachment[] = [];
182 attachments.forEach(a => {
183 if (!uniqueAttachments.some(ua => ua.image_url === a.image_url)) {
184 uniqueAttachments.push(a);
185 }
186 });
187 return uniqueAttachments;
188}
189
190/**
191 * Find issue mentions in body and replace them with links.
192 *
193 * @param body message to modify
194 * @param repo repository information
195 * @return string with issue mentions replaced with links
196 */
197export function linkIssues(body: string, repo: any): string {
198 if (!body || body.length === 0) {
199 return body;
200 }
201
202 const splitter = /(\[.+?\](?:\[.*?\]|\(.+?\)|:\s*http.*)|^```.*\n[\S\s]*?^```\s*\n|<.+?>)/m;
203 const bodyParts = body.split(splitter);
204 const baseUrl = htmlUrl(repo);
205
206 for (let j = 0; j < bodyParts.length; j += 2) {
207 let newPart = bodyParts[j];
208 const allIssueMentions = getIssueMentions(newPart);
209 allIssueMentions.forEach(i => {
210 const iMatchPrefix = (i.indexOf("#") === 0) ? `^|\\W` : repoIssueMatchPrefix;
211 const iRegExp = new RegExp(`(${iMatchPrefix})${i}(?!\\w)`, "g");
212 const iSlug = (i.indexOf("#") === 0) ? `${repo.owner}/${repo.name}${i}` : i;
213 const iUrlPath = iSlug.replace("#", "/issues/");
214 const iLink = slack.url(`${baseUrl}/${iUrlPath}`, i);
215 newPart = newPart.replace(iRegExp, `\$1${iLink}`);
216 });
217 bodyParts[j] = newPart;
218 }
219
220 return bodyParts.join("");
221}
222
223const gitHubUserMatch = "[a-zA-Z\\d]+(?:-[a-zA-Z\\d]+)*";
224
225/**
226 * Regular expression to find issue mentions. There are capture
227 * groups for the issue repository owner, repository name, and issue
228 * number. The capture groups for repository owner and name are
229 * optional and therefore may be null, although if one is set, the
230 * other should be as well.
231 *
232 * The rules for preceding characters is different for current repo
233 * matches, e.g., "#43", and other repo matches, e.g., "some/repo#44".
234 * Current repo matches allow anything but word characters to precede
235 * them. Other repo matches only allow a few other characters to
236 * preceed them.
237 */
238const repoIssueMatchPrefix = "^|[[\\s:({]";
239// tslint:disable-next-line:max-line-length
240const issueMentionMatch = `(?:^|(?:${repoIssueMatchPrefix})(${gitHubUserMatch})\/(${gitHubUserMatch})|\\W)#([1-9]\\d*)(?!\\w)`;
241const issueMentionRegExp = new RegExp(issueMentionMatch, "g");
242
243/**
244 * Find all issue mentions and return an array of unique issue
245 * mentions as "#3" and "owner/repo#5".
246 *
247 * @param msg string that may contain mentions
248 * @return unique list of issue mentions as #N or O/R#N
249 */
250export function getIssueMentions(msg: string = ""): string[] {
251 const allMentions: string[] = [];
252 let matches: string[];
253 // tslint:disable-next-line:no-conditional-assignment
254 while (matches = issueMentionRegExp.exec(msg)) {
255 const owner = matches[1];
256 const repo = matches[2];
257 const issue = matches[3];
258 const slug = (owner && repo) ? `${owner}/${repo}` : "";
259 allMentions.push(`${slug}#${issue}`);
260 }
261
262 return _.uniq(allMentions);
263}