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