blob: d7ac843069011d0ff3e8edbb2b9390b6a0c89db6 [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
import {prpcClient} from 'prpc-client-instance.js';
import {generateProjectIssueURL} from 'shared/helpers.js'
/* eslint-disable max-len */
// When crbug links don't specify a project, the default project is Chromium.
const CRBUG_DEFAULT_PROJECT = 'chromium';
const CRBUG_LINK_RE = /(\b(https?:\/\/)?crbug\.com\/)((\b[-a-z0-9]+)(\/))?(\d+)\b(\#c[0-9]+)?/gi;
const CRBUG_LINK_RE_PROJECT_GROUP = 4;
const CRBUG_LINK_RE_ID_GROUP = 6;
const CRBUG_LINK_RE_COMMENT_GROUP = 7;
const ISSUE_TRACKER_RE = /(\b(issues?|bugs?)[ \t]*(:|=|\b)|\bfixed[ \t]*:)([ \t]*((\b[-a-z0-9]+)[:\#])?(\#?)(\d+)\b(,?[ \t]*(and|or)?)?)+/gi;
const PROJECT_LOCALID_RE = /((\b(issue|bug)[ \t]*(:|=)?[ \t]*|\bfixed[ \t]*:[ \t]*)?((\b[-a-z0-9]+)[:\#])?(\#?)(\d+))/gi;
const PROJECT_COMMENT_BUG_RE = /(((\b(issue|bug)[ \t]*(:|=)?[ \t]*)(\#?)(\d+)[ \t*])?((\b((comment)[ \t]*(:|=)?[ \t]*(\#?))|(\B((\#))(c)))(\d+)))/gi;
const PROJECT_LOCALID_RE_PROJECT_GROUP = 6;
const PROJECT_LOCALID_RE_ID_GROUP = 8;
const IMPLIED_EMAIL_RE = /\b[a-z]((-|\.)?[a-z0-9])+@[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b/gi;
// TODO(zhangtiff): Replace (^|[^-/._]) with (?<![-/._]) on the 3 Regexes below
// once Firefox supports lookaheads.
const SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/|mailto:)?(go|g|shortn|who|teams)\/([^\s<]+)/gi;
const NUMERIC_SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/)?(b|t|o|omg|cl|cr|fxr|fxrev|fxb|tqr)\/([0-9]+)/gi;
const IMPLIED_LINK_RE = /(^|[^-\/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b(\/[^\s<]*)?/gi;
const IS_LINK_RE = /()\b(https?:\/\/|ftp:\/\/|mailto:)([^\s<]+)/gi;
const GIT_HASH_RE = /\b(r(evision\s+#?)?)?([a-f0-9]{40})\b/gi;
const SVN_REF_RE = /\b(r(evision\s+#?)?)([0-9]{4,7})\b/gi;
const NEW_LINE_REGEX = /^(\r\n?|\n)$/;
const NEW_LINE_OR_BOLD_REGEX = /(<b>[^<\n]+<\/b>)|(\r\n?|\n)/;
// The revNum is in the same position for the above two Regexes. The
// extraction function uses this similar format to allow switching out
// Regexes easily, so be careful about changing GIT_HASH_RE and SVN_HASH_RE.
const REV_NUM_GROUP = 3;
const LINK_TRAILING_CHARS = [
[null, ':'],
[null, '.'],
[null, ','],
[null, '>'],
['(', ')'],
['[', ']'],
['{', '}'],
['\'', '\''],
['"', '"'],
];
const GOOG_SHORT_LINK_RE = /^(b|t|o|omg|cl|cr|go|g|shortn|who|teams|fxr|fxrev|fxb|tqr)\/.*/gi;
/* eslint-enable max-len */
const Components = new Map();
// TODO(zosha): Combine functions of Component 00 with 01 so that
// user can only reference valid issues in the issue/comment linking.
// Allow user to reference multiple comments on the same issue.
// Additionally, allow for the user to reference this on a specific project.
// Note: the order of the components is important for proper autolinking.
Components.set(
'00-commentbug',
{
lookup: null,
extractRefs: null,
refRegs: [PROJECT_COMMENT_BUG_RE],
replacer: ReplaceCommentBugRef,
},
);
Components.set(
'01-tracker-crbug',
{
lookup: LookupReferencedIssues,
extractRefs: ExtractCrbugProjectAndIssueIds,
refRegs: [CRBUG_LINK_RE],
replacer: ReplaceCrbugIssueRef,
},
);
Components.set(
'02-full-urls',
{
lookup: null,
extractRefs: (match, _currentProjectName) => {
return [match[0]];
},
refRegs: [IS_LINK_RE],
replacer: ReplaceLinkRef,
},
);
Components.set(
'03-user-emails',
{
lookup: LookupReferencedUsers,
extractRefs: (match, _currentProjectName) => {
return [match[0]];
},
refRegs: [IMPLIED_EMAIL_RE],
replacer: ReplaceUserRef,
},
);
Components.set(
'04-tracker-regular',
{
lookup: LookupReferencedIssues,
extractRefs: ExtractTrackerProjectAndIssueIds,
refRegs: [ISSUE_TRACKER_RE],
replacer: ReplaceTrackerIssueRef,
},
);
Components.set(
'05-linkify-shorthand',
{
lookup: null,
extractRefs: (match, _currentProjectName) => {
return [match[0]];
},
refRegs: [
SHORT_LINK_RE,
NUMERIC_SHORT_LINK_RE,
IMPLIED_LINK_RE,
],
replacer: ReplaceLinkRef,
},
);
Components.set(
'06-versioncontrol',
{
lookup: null,
extractRefs: (match, _currentProjectName) => {
return [match[0]];
},
refRegs: [GIT_HASH_RE, SVN_REF_RE],
replacer: ReplaceRevisionRef,
},
);
// Lookup referenced artifacts functions.
function LookupReferencedIssues(issueRefs, componentName) {
return new Promise((resolve, reject) => {
issueRefs = issueRefs.filter(
({projectName, localId}) => projectName && parseInt(localId));
const listReferencedIssues = prpcClient.call(
'monorail.Issues', 'ListReferencedIssues', {issueRefs});
return listReferencedIssues.then((response) => {
resolve({'componentName': componentName, 'existingRefs': response});
});
});
}
function LookupReferencedUsers(emails, componentName) {
return new Promise((resolve, reject) => {
const userRefs = emails.map((displayName) => {
return {displayName};
});
const listReferencedUsers = prpcClient.call(
'monorail.Users', 'ListReferencedUsers', {userRefs});
return listReferencedUsers.then((response) => {
resolve({'componentName': componentName, 'existingRefs': response});
});
});
}
// Extract referenced artifacts info functions.
function ExtractCrbugProjectAndIssueIds(match, _currentProjectName) {
// When crbug links don't specify a project, the default project is Chromium.
const projectName = match[CRBUG_LINK_RE_PROJECT_GROUP] ||
CRBUG_DEFAULT_PROJECT;
const localId = match[CRBUG_LINK_RE_ID_GROUP];
return [{projectName: projectName, localId: localId}];
}
function ExtractTrackerProjectAndIssueIds(match, currentProjectName) {
const issueRefRE = PROJECT_LOCALID_RE;
let refMatch;
const refs = [];
while ((refMatch = issueRefRE.exec(match[0])) !== null) {
if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
}
refs.push({
projectName: currentProjectName,
localId: refMatch[PROJECT_LOCALID_RE_ID_GROUP],
});
}
return refs;
}
// Replace plain text references with links functions.
function ReplaceIssueRef(stringMatch, projectName, localId, components,
commentId) {
if (components.openRefs && components.openRefs.length) {
const openRef = components.openRefs.find((ref) => {
return ref.localId && ref.projectName && (ref.localId == localId) &&
(ref.projectName.toLowerCase() === projectName.toLowerCase());
});
if (openRef) {
return createIssueRefRun(
projectName, localId, openRef.summary, false, stringMatch, commentId);
}
}
if (components.closedRefs && components.closedRefs.length) {
const closedRef = components.closedRefs.find((ref) => {
return ref.localId && ref.projectName && (ref.localId == localId) &&
(ref.projectName.toLowerCase() === projectName.toLowerCase());
});
if (closedRef) {
return createIssueRefRun(
projectName, localId, closedRef.summary, true, stringMatch,
commentId);
}
}
return {content: stringMatch};
}
function ReplaceCrbugIssueRef(match, components, _currentProjectName) {
components = components || {};
// When crbug links don't specify a project, the default project is Chromium.
const projectName =
match[CRBUG_LINK_RE_PROJECT_GROUP] || CRBUG_DEFAULT_PROJECT;
const localId = match[CRBUG_LINK_RE_ID_GROUP];
let commentId = '';
if (match[CRBUG_LINK_RE_COMMENT_GROUP] !== undefined) {
commentId = match[CRBUG_LINK_RE_COMMENT_GROUP];
}
return [ReplaceIssueRef(match[0], projectName, localId, components,
commentId)];
}
function ReplaceTrackerIssueRef(match, components, currentProjectName) {
components = components || {};
const issueRefRE = PROJECT_LOCALID_RE;
const commentId = '';
const textRuns = [];
let refMatch;
let pos = 0;
while ((refMatch = issueRefRE.exec(match[0])) !== null) {
if (refMatch.index > pos) {
// Create textrun for content between previous and current match.
textRuns.push({content: match[0].slice(pos, refMatch.index)});
}
if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
}
textRuns.push(ReplaceIssueRef(
refMatch[0], currentProjectName,
refMatch[PROJECT_LOCALID_RE_ID_GROUP], components, commentId));
pos = refMatch.index + refMatch[0].length;
}
if (match[0].slice(pos) !== '') {
textRuns.push({content: match[0].slice(pos)});
}
return textRuns;
}
function ReplaceUserRef(match, components, _currentProjectName) {
components = components || {};
const textRun = {content: match[0], tag: 'a'};
if (components.users && components.users.length) {
const existingUser = components.users.find((user) => {
return user.displayName.toLowerCase() === match[0].toLowerCase();
});
if (existingUser) {
textRun.href = `/u/${match[0]}`;
return [textRun];
}
}
textRun.href = `mailto:${match[0]}`;
return [textRun];
}
function ReplaceCommentBugRef(match) {
let textRun;
const issueNum = match[7];
const commentNum = match[18];
if (issueNum && commentNum) {
textRun = {content: match[0], tag: 'a', href: `?id=${issueNum}#c${commentNum}`};
} else if (commentNum) {
textRun = {content: match[0], tag: 'a', href: `#c${commentNum}`};
} else {
textRun = {content: match[0]};
}
return [textRun];
}
function ReplaceLinkRef(match, _components, _currentProjectName) {
const textRuns = [];
let content = match[0];
let trailing = '';
if (match[1]) {
textRuns.push({content: match[1]});
content = content.slice(match[1].length);
}
LINK_TRAILING_CHARS.forEach(([begin, end]) => {
if (content.endsWith(end)) {
if (!begin || !content.slice(0, -end.length).includes(begin)) {
trailing = end + trailing;
content = content.slice(0, -end.length);
}
}
});
let href = content;
const lowerHref = href.toLowerCase();
if (!lowerHref.startsWith('http') && !lowerHref.startsWith('ftp') &&
!lowerHref.startsWith('mailto')) {
// Prepend google-internal short links with http to
// prevent HTTPS error interstitial.
// SHORT_LINK_RE should not be used here as it might be
// in the middle of another match() process in an outer loop.
if (GOOG_SHORT_LINK_RE.test(lowerHref)) {
href = 'http://' + href;
} else {
href = 'https://' + href;
}
GOOG_SHORT_LINK_RE.lastIndex = 0;
}
textRuns.push({content: content, tag: 'a', href: href});
if (trailing.length) {
textRuns.push({content: trailing});
}
return textRuns;
}
function ReplaceRevisionRef(
match, _components, _currentProjectName, revisionUrlFormat) {
const content = match[0];
const href = revisionUrlFormat.replace('{revnum}', match[REV_NUM_GROUP]);
return [{content: content, tag: 'a', href: href}];
}
// Create custom textrun functions.
function createIssueRefRun(projectName, localId, summary, isClosed, content,
commentId) {
const params = {'id': localId};
const href = generateProjectIssueURL(projectName, '/detail', params)
return {
tag: 'a',
css: isClosed ? 'strike-through' : '',
href: href + commentId,
title: summary || '',
content: content,
};
}
/**
* @typedef {Object} CommentReference
* @property {string} componentName A key identifying the kind of autolinking
* text the reference matches.
* @property {Array<any>} existingRefs Array of full data for referenced
* Objects. Each entry in this Array could be any kind of data depending
* on what the text references. For example, the Array could contain Issue
* or User Objects.
*/
/**
* Iterates through a list of comments, requests data for referenced objects
* in those comments, and returns all fetched data.
* @param {Array<IssueComment>} comments Array of comments to check.
* @param {string} currentProjectName Project these comments exist in the
* context of.
* @return {Promise<Array<CommentReference>>}
*/
function getReferencedArtifacts(comments, currentProjectName) {
return new Promise((resolve, reject) => {
const fetchPromises = [];
Components.forEach(({lookup, extractRefs, refRegs}, componentName) => {
if (lookup !== null) {
const refs = [];
refRegs.forEach((re) => {
let match;
comments.forEach((comment) => {
while ((match = re.exec(comment.content)) !== null) {
refs.push(...extractRefs(match, currentProjectName));
};
});
});
if (refs.length) {
fetchPromises.push(lookup(refs, componentName));
}
}
});
resolve(Promise.all(fetchPromises));
});
}
function markupAutolinks(
plainString, componentRefs, currentProjectName, revisionUrlFormat) {
plainString = plainString || '';
const chunks = plainString.trim().split(NEW_LINE_OR_BOLD_REGEX);
const textRuns = [];
chunks.filter(Boolean).forEach((chunk) => {
if (chunk.match(NEW_LINE_REGEX)) {
textRuns.push({tag: 'br'});
} else if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
textRuns.push({content: chunk.slice(3, -4), tag: 'b'});
} else {
textRuns.push(
...autolinkChunk(
chunk, componentRefs, currentProjectName, revisionUrlFormat));
}
});
return textRuns;
}
function autolinkChunk(
chunk, componentRefs, currentProjectName, revisionUrlFormat) {
let textRuns = [{content: chunk}];
Components.forEach(({refRegs, replacer}, componentName) => {
refRegs.forEach((re) => {
textRuns = applyLinks(
textRuns, replacer, re, componentRefs.get(componentName),
currentProjectName, revisionUrlFormat);
});
});
return textRuns;
}
function applyLinks(
textRuns, replacer, re, existingRefs, currentProjectName,
revisionUrlFormat) {
const resultRuns = [];
textRuns.forEach((textRun) => {
if (textRun.tag) {
resultRuns.push(textRun);
} else {
const content = textRun.content;
let pos = 0;
let match;
while ((match = re.exec(content)) !== null) {
if (match.index > pos) {
// Create textrun for content between previous and current match.
resultRuns.push({content: content.slice(pos, match.index)});
}
resultRuns.push(
...replacer(
match, existingRefs, currentProjectName, revisionUrlFormat));
pos = match.index + match[0].length;
}
if (content.slice(pos) !== '') {
resultRuns.push({content: content.slice(pos)});
}
}
});
return resultRuns;
}
export const autolink = {Components, getReferencedArtifacts, markupAutolinks};