blob: 5419d9cfff8824dd90da3b8ae0ba57aeacbf5e2b [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6import {prpcClient} from 'prpc-client-instance.js';
7
8/* eslint-disable max-len */
9// When crbug links don't specify a project, the default project is Chromium.
10const CRBUG_DEFAULT_PROJECT = 'chromium';
11const CRBUG_LINK_RE = /(\b(https?:\/\/)?crbug\.com\/)((\b[-a-z0-9]+)(\/))?(\d+)\b(\#c[0-9]+)?/gi;
12const CRBUG_LINK_RE_PROJECT_GROUP = 4;
13const CRBUG_LINK_RE_ID_GROUP = 6;
14const CRBUG_LINK_RE_COMMENT_GROUP = 7;
15const ISSUE_TRACKER_RE = /(\b(issues?|bugs?)[ \t]*(:|=|\b)|\bfixed[ \t]*:)([ \t]*((\b[-a-z0-9]+)[:\#])?(\#?)(\d+)\b(,?[ \t]*(and|or)?)?)+/gi;
16const PROJECT_LOCALID_RE = /((\b(issue|bug)[ \t]*(:|=)?[ \t]*|\bfixed[ \t]*:[ \t]*)?((\b[-a-z0-9]+)[:\#])?(\#?)(\d+))/gi;
17const PROJECT_COMMENT_BUG_RE = /(((\b(issue|bug)[ \t]*(:|=)?[ \t]*)(\#?)(\d+)[ \t*])?((\b((comment)[ \t]*(:|=)?[ \t]*(\#?))|(\B((\#))(c)))(\d+)))/gi;
18const PROJECT_LOCALID_RE_PROJECT_GROUP = 6;
19const PROJECT_LOCALID_RE_ID_GROUP = 8;
20const IMPLIED_EMAIL_RE = /\b[a-z]((-|\.)?[a-z0-9])+@[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b/gi;
21// TODO(zhangtiff): Replace (^|[^-/._]) with (?<![-/._]) on the 3 Regexes below
22// once Firefox supports lookaheads.
23const SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/|mailto:)?(go|g|shortn|who|teams)\/([^\s<]+)/gi;
24const NUMERIC_SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/)?(b|t|o|omg|cl|cr|fxr|fxrev|fxb|tqr)\/([0-9]+)/gi;
25const IMPLIED_LINK_RE = /(^|[^-\/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b(\/[^\s<]*)?/gi;
26const IS_LINK_RE = /()\b(https?:\/\/|ftp:\/\/|mailto:)([^\s<]+)/gi;
27const GIT_HASH_RE = /\b(r(evision\s+#?)?)?([a-f0-9]{40})\b/gi;
28const SVN_REF_RE = /\b(r(evision\s+#?)?)([0-9]{4,7})\b/gi;
29const NEW_LINE_REGEX = /^(\r\n?|\n)$/;
30const NEW_LINE_OR_BOLD_REGEX = /(<b>[^<\n]+<\/b>)|(\r\n?|\n)/;
31// The revNum is in the same position for the above two Regexes. The
32// extraction function uses this similar format to allow switching out
33// Regexes easily, so be careful about changing GIT_HASH_RE and SVN_HASH_RE.
34const REV_NUM_GROUP = 3;
35const LINK_TRAILING_CHARS = [
36 [null, ':'],
37 [null, '.'],
38 [null, ','],
39 [null, '>'],
40 ['(', ')'],
41 ['[', ']'],
42 ['{', '}'],
43 ['\'', '\''],
44 ['"', '"'],
45];
46const GOOG_SHORT_LINK_RE = /^(b|t|o|omg|cl|cr|go|g|shortn|who|teams|fxr|fxrev|fxb|tqr)\/.*/gi;
47/* eslint-enable max-len */
48
49const Components = new Map();
50// TODO(zosha): Combine functions of Component 00 with 01 so that
51// user can only reference valid issues in the issue/comment linking.
52// Allow user to reference multiple comments on the same issue.
53// Additionally, allow for the user to reference this on a specific project.
54// Note: the order of the components is important for proper autolinking.
55Components.set(
56 '00-commentbug',
57 {
58 lookup: null,
59 extractRefs: null,
60 refRegs: [PROJECT_COMMENT_BUG_RE],
61 replacer: ReplaceCommentBugRef,
62 },
63);
64Components.set(
65 '01-tracker-crbug',
66 {
67 lookup: LookupReferencedIssues,
68 extractRefs: ExtractCrbugProjectAndIssueIds,
69 refRegs: [CRBUG_LINK_RE],
70 replacer: ReplaceCrbugIssueRef,
71
72 },
73);
74Components.set(
75 '02-full-urls',
76 {
77 lookup: null,
78 extractRefs: (match, _currentProjectName) => {
79 return [match[0]];
80 },
81 refRegs: [IS_LINK_RE],
82 replacer: ReplaceLinkRef,
83 },
84);
85Components.set(
86 '03-user-emails',
87 {
88 lookup: LookupReferencedUsers,
89 extractRefs: (match, _currentProjectName) => {
90 return [match[0]];
91 },
92 refRegs: [IMPLIED_EMAIL_RE],
93 replacer: ReplaceUserRef,
94 },
95);
96Components.set(
97 '04-tracker-regular',
98 {
99 lookup: LookupReferencedIssues,
100 extractRefs: ExtractTrackerProjectAndIssueIds,
101 refRegs: [ISSUE_TRACKER_RE],
102 replacer: ReplaceTrackerIssueRef,
103 },
104);
105Components.set(
106 '05-linkify-shorthand',
107 {
108 lookup: null,
109 extractRefs: (match, _currentProjectName) => {
110 return [match[0]];
111 },
112 refRegs: [
113 SHORT_LINK_RE,
114 NUMERIC_SHORT_LINK_RE,
115 IMPLIED_LINK_RE,
116 ],
117 replacer: ReplaceLinkRef,
118 },
119);
120Components.set(
121 '06-versioncontrol',
122 {
123 lookup: null,
124 extractRefs: (match, _currentProjectName) => {
125 return [match[0]];
126 },
127 refRegs: [GIT_HASH_RE, SVN_REF_RE],
128 replacer: ReplaceRevisionRef,
129 },
130);
131
132// Lookup referenced artifacts functions.
133function LookupReferencedIssues(issueRefs, componentName) {
134 return new Promise((resolve, reject) => {
135 issueRefs = issueRefs.filter(
136 ({projectName, localId}) => projectName && parseInt(localId));
137 const listReferencedIssues = prpcClient.call(
138 'monorail.Issues', 'ListReferencedIssues', {issueRefs});
139 return listReferencedIssues.then((response) => {
140 resolve({'componentName': componentName, 'existingRefs': response});
141 });
142 });
143}
144
145function LookupReferencedUsers(emails, componentName) {
146 return new Promise((resolve, reject) => {
147 const userRefs = emails.map((displayName) => {
148 return {displayName};
149 });
150 const listReferencedUsers = prpcClient.call(
151 'monorail.Users', 'ListReferencedUsers', {userRefs});
152 return listReferencedUsers.then((response) => {
153 resolve({'componentName': componentName, 'existingRefs': response});
154 });
155 });
156}
157
158// Extract referenced artifacts info functions.
159function ExtractCrbugProjectAndIssueIds(match, _currentProjectName) {
160 // When crbug links don't specify a project, the default project is Chromium.
161 const projectName = match[CRBUG_LINK_RE_PROJECT_GROUP] ||
162 CRBUG_DEFAULT_PROJECT;
163 const localId = match[CRBUG_LINK_RE_ID_GROUP];
164 return [{projectName: projectName, localId: localId}];
165}
166
167function ExtractTrackerProjectAndIssueIds(match, currentProjectName) {
168 const issueRefRE = PROJECT_LOCALID_RE;
169 let refMatch;
170 const refs = [];
171 while ((refMatch = issueRefRE.exec(match[0])) !== null) {
172 if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
173 currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
174 }
175 refs.push({
176 projectName: currentProjectName,
177 localId: refMatch[PROJECT_LOCALID_RE_ID_GROUP],
178 });
179 }
180 return refs;
181}
182
183// Replace plain text references with links functions.
184function ReplaceIssueRef(stringMatch, projectName, localId, components,
185 commentId) {
186 if (components.openRefs && components.openRefs.length) {
187 const openRef = components.openRefs.find((ref) => {
188 return ref.localId && ref.projectName && (ref.localId == localId) &&
189 (ref.projectName.toLowerCase() === projectName.toLowerCase());
190 });
191 if (openRef) {
192 return createIssueRefRun(
193 projectName, localId, openRef.summary, false, stringMatch, commentId);
194 }
195 }
196 if (components.closedRefs && components.closedRefs.length) {
197 const closedRef = components.closedRefs.find((ref) => {
198 return ref.localId && ref.projectName && (ref.localId == localId) &&
199 (ref.projectName.toLowerCase() === projectName.toLowerCase());
200 });
201 if (closedRef) {
202 return createIssueRefRun(
203 projectName, localId, closedRef.summary, true, stringMatch,
204 commentId);
205 }
206 }
207 return {content: stringMatch};
208}
209
210function ReplaceCrbugIssueRef(match, components, _currentProjectName) {
211 components = components || {};
212 // When crbug links don't specify a project, the default project is Chromium.
213 const projectName =
214 match[CRBUG_LINK_RE_PROJECT_GROUP] || CRBUG_DEFAULT_PROJECT;
215 const localId = match[CRBUG_LINK_RE_ID_GROUP];
216 let commentId = '';
217 if (match[CRBUG_LINK_RE_COMMENT_GROUP] !== undefined) {
218 commentId = match[CRBUG_LINK_RE_COMMENT_GROUP];
219 }
220 return [ReplaceIssueRef(match[0], projectName, localId, components,
221 commentId)];
222}
223
224function ReplaceTrackerIssueRef(match, components, currentProjectName) {
225 components = components || {};
226 const issueRefRE = PROJECT_LOCALID_RE;
227 const commentId = '';
228 const textRuns = [];
229 let refMatch;
230 let pos = 0;
231 while ((refMatch = issueRefRE.exec(match[0])) !== null) {
232 if (refMatch.index > pos) {
233 // Create textrun for content between previous and current match.
234 textRuns.push({content: match[0].slice(pos, refMatch.index)});
235 }
236 if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
237 currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
238 }
239 textRuns.push(ReplaceIssueRef(
240 refMatch[0], currentProjectName,
241 refMatch[PROJECT_LOCALID_RE_ID_GROUP], components, commentId));
242 pos = refMatch.index + refMatch[0].length;
243 }
244 if (match[0].slice(pos) !== '') {
245 textRuns.push({content: match[0].slice(pos)});
246 }
247 return textRuns;
248}
249
250function ReplaceUserRef(match, components, _currentProjectName) {
251 components = components || {};
252 const textRun = {content: match[0], tag: 'a'};
253 if (components.users && components.users.length) {
254 const existingUser = components.users.find((user) => {
255 return user.displayName.toLowerCase() === match[0].toLowerCase();
256 });
257 if (existingUser) {
258 textRun.href = `/u/${match[0]}`;
259 return [textRun];
260 }
261 }
262 textRun.href = `mailto:${match[0]}`;
263 return [textRun];
264}
265
266function ReplaceCommentBugRef(match) {
267 let textRun;
268 const issueNum = match[7];
269 const commentNum = match[18];
270 if (issueNum && commentNum) {
271 textRun = {content: match[0], tag: 'a', href: `?id=${issueNum}#c${commentNum}`};
272 } else if (commentNum) {
273 textRun = {content: match[0], tag: 'a', href: `#c${commentNum}`};
274 } else {
275 textRun = {content: match[0]};
276 }
277 return [textRun];
278}
279
280function ReplaceLinkRef(match, _components, _currentProjectName) {
281 const textRuns = [];
282 let content = match[0];
283 let trailing = '';
284 if (match[1]) {
285 textRuns.push({content: match[1]});
286 content = content.slice(match[1].length);
287 }
288 LINK_TRAILING_CHARS.forEach(([begin, end]) => {
289 if (content.endsWith(end)) {
290 if (!begin || !content.slice(0, -end.length).includes(begin)) {
291 trailing = end + trailing;
292 content = content.slice(0, -end.length);
293 }
294 }
295 });
296 let href = content;
297 const lowerHref = href.toLowerCase();
298 if (!lowerHref.startsWith('http') && !lowerHref.startsWith('ftp') &&
299 !lowerHref.startsWith('mailto')) {
300 // Prepend google-internal short links with http to
301 // prevent HTTPS error interstitial.
302 // SHORT_LINK_RE should not be used here as it might be
303 // in the middle of another match() process in an outer loop.
304 if (GOOG_SHORT_LINK_RE.test(lowerHref)) {
305 href = 'http://' + href;
306 } else {
307 href = 'https://' + href;
308 }
309 GOOG_SHORT_LINK_RE.lastIndex = 0;
310 }
311 textRuns.push({content: content, tag: 'a', href: href});
312 if (trailing.length) {
313 textRuns.push({content: trailing});
314 }
315 return textRuns;
316}
317
318function ReplaceRevisionRef(
319 match, _components, _currentProjectName, revisionUrlFormat) {
320 const content = match[0];
321 const href = revisionUrlFormat.replace('{revnum}', match[REV_NUM_GROUP]);
322 return [{content: content, tag: 'a', href: href}];
323}
324
325// Create custom textrun functions.
326function createIssueRefRun(projectName, localId, summary, isClosed, content,
327 commentId) {
328 return {
329 tag: 'a',
330 css: isClosed ? 'strike-through' : '',
331 href: `/p/${projectName}/issues/detail?id=${localId}${commentId}`,
332 title: summary || '',
333 content: content,
334 };
335}
336
337/**
338 * @typedef {Object} CommentReference
339 * @property {string} componentName A key identifying the kind of autolinking
340 * text the reference matches.
341 * @property {Array<any>} existingRefs Array of full data for referenced
342 * Objects. Each entry in this Array could be any kind of data depending
343 * on what the text references. For example, the Array could contain Issue
344 * or User Objects.
345 */
346
347/**
348 * Iterates through a list of comments, requests data for referenced objects
349 * in those comments, and returns all fetched data.
350 * @param {Array<IssueComment>} comments Array of comments to check.
351 * @param {string} currentProjectName Project these comments exist in the
352 * context of.
353 * @return {Promise<Array<CommentReference>>}
354 */
355function getReferencedArtifacts(comments, currentProjectName) {
356 return new Promise((resolve, reject) => {
357 const fetchPromises = [];
358 Components.forEach(({lookup, extractRefs, refRegs}, componentName) => {
359 if (lookup !== null) {
360 const refs = [];
361 refRegs.forEach((re) => {
362 let match;
363 comments.forEach((comment) => {
364 while ((match = re.exec(comment.content)) !== null) {
365 refs.push(...extractRefs(match, currentProjectName));
366 };
367 });
368 });
369 if (refs.length) {
370 fetchPromises.push(lookup(refs, componentName));
371 }
372 }
373 });
374 resolve(Promise.all(fetchPromises));
375 });
376}
377
378function markupAutolinks(
379 plainString, componentRefs, currentProjectName, revisionUrlFormat) {
380 plainString = plainString || '';
381 const chunks = plainString.trim().split(NEW_LINE_OR_BOLD_REGEX);
382 const textRuns = [];
383 chunks.filter(Boolean).forEach((chunk) => {
384 if (chunk.match(NEW_LINE_REGEX)) {
385 textRuns.push({tag: 'br'});
386 } else if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
387 textRuns.push({content: chunk.slice(3, -4), tag: 'b'});
388 } else {
389 textRuns.push(
390 ...autolinkChunk(
391 chunk, componentRefs, currentProjectName, revisionUrlFormat));
392 }
393 });
394 return textRuns;
395}
396
397function autolinkChunk(
398 chunk, componentRefs, currentProjectName, revisionUrlFormat) {
399 let textRuns = [{content: chunk}];
400 Components.forEach(({refRegs, replacer}, componentName) => {
401 refRegs.forEach((re) => {
402 textRuns = applyLinks(
403 textRuns, replacer, re, componentRefs.get(componentName),
404 currentProjectName, revisionUrlFormat);
405 });
406 });
407 return textRuns;
408}
409
410function applyLinks(
411 textRuns, replacer, re, existingRefs, currentProjectName,
412 revisionUrlFormat) {
413 const resultRuns = [];
414 textRuns.forEach((textRun) => {
415 if (textRun.tag) {
416 resultRuns.push(textRun);
417 } else {
418 const content = textRun.content;
419 let pos = 0;
420 let match;
421 while ((match = re.exec(content)) !== null) {
422 if (match.index > pos) {
423 // Create textrun for content between previous and current match.
424 resultRuns.push({content: content.slice(pos, match.index)});
425 }
426 resultRuns.push(
427 ...replacer(
428 match, existingRefs, currentProjectName, revisionUrlFormat));
429 pos = match.index + match[0].length;
430 }
431 if (content.slice(pos) !== '') {
432 resultRuns.push({content: content.slice(pos)});
433 }
434 }
435 });
436 return resultRuns;
437}
438
439
440export const autolink = {Components, getReferencedArtifacts, markupAutolinks};