| // 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}; |