Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | // 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'; |
| 6 | import {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. |
| 10 | const CRBUG_DEFAULT_PROJECT = 'chromium'; |
| 11 | const CRBUG_LINK_RE = /(\b(https?:\/\/)?crbug\.com\/)((\b[-a-z0-9]+)(\/))?(\d+)\b(\#c[0-9]+)?/gi; |
| 12 | const CRBUG_LINK_RE_PROJECT_GROUP = 4; |
| 13 | const CRBUG_LINK_RE_ID_GROUP = 6; |
| 14 | const CRBUG_LINK_RE_COMMENT_GROUP = 7; |
| 15 | const ISSUE_TRACKER_RE = /(\b(issues?|bugs?)[ \t]*(:|=|\b)|\bfixed[ \t]*:)([ \t]*((\b[-a-z0-9]+)[:\#])?(\#?)(\d+)\b(,?[ \t]*(and|or)?)?)+/gi; |
| 16 | const PROJECT_LOCALID_RE = /((\b(issue|bug)[ \t]*(:|=)?[ \t]*|\bfixed[ \t]*:[ \t]*)?((\b[-a-z0-9]+)[:\#])?(\#?)(\d+))/gi; |
| 17 | const PROJECT_COMMENT_BUG_RE = /(((\b(issue|bug)[ \t]*(:|=)?[ \t]*)(\#?)(\d+)[ \t*])?((\b((comment)[ \t]*(:|=)?[ \t]*(\#?))|(\B((\#))(c)))(\d+)))/gi; |
| 18 | const PROJECT_LOCALID_RE_PROJECT_GROUP = 6; |
| 19 | const PROJECT_LOCALID_RE_ID_GROUP = 8; |
| 20 | const 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. |
| 23 | const SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/|mailto:)?(go|g|shortn|who|teams)\/([^\s<]+)/gi; |
| 24 | const NUMERIC_SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/)?(b|t|o|omg|cl|cr|fxr|fxrev|fxb|tqr)\/([0-9]+)/gi; |
| 25 | const IMPLIED_LINK_RE = /(^|[^-\/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b(\/[^\s<]*)?/gi; |
| 26 | const IS_LINK_RE = /()\b(https?:\/\/|ftp:\/\/|mailto:)([^\s<]+)/gi; |
| 27 | const GIT_HASH_RE = /\b(r(evision\s+#?)?)?([a-f0-9]{40})\b/gi; |
| 28 | const SVN_REF_RE = /\b(r(evision\s+#?)?)([0-9]{4,7})\b/gi; |
| 29 | const NEW_LINE_REGEX = /^(\r\n?|\n)$/; |
| 30 | const 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. |
| 34 | const REV_NUM_GROUP = 3; |
| 35 | const LINK_TRAILING_CHARS = [ |
| 36 | [null, ':'], |
| 37 | [null, '.'], |
| 38 | [null, ','], |
| 39 | [null, '>'], |
| 40 | ['(', ')'], |
| 41 | ['[', ']'], |
| 42 | ['{', '}'], |
| 43 | ['\'', '\''], |
| 44 | ['"', '"'], |
| 45 | ]; |
| 46 | const 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 | |
| 49 | const 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. |
| 55 | Components.set( |
| 56 | '00-commentbug', |
| 57 | { |
| 58 | lookup: null, |
| 59 | extractRefs: null, |
| 60 | refRegs: [PROJECT_COMMENT_BUG_RE], |
| 61 | replacer: ReplaceCommentBugRef, |
| 62 | }, |
| 63 | ); |
| 64 | Components.set( |
| 65 | '01-tracker-crbug', |
| 66 | { |
| 67 | lookup: LookupReferencedIssues, |
| 68 | extractRefs: ExtractCrbugProjectAndIssueIds, |
| 69 | refRegs: [CRBUG_LINK_RE], |
| 70 | replacer: ReplaceCrbugIssueRef, |
| 71 | |
| 72 | }, |
| 73 | ); |
| 74 | Components.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 | ); |
| 85 | Components.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 | ); |
| 96 | Components.set( |
| 97 | '04-tracker-regular', |
| 98 | { |
| 99 | lookup: LookupReferencedIssues, |
| 100 | extractRefs: ExtractTrackerProjectAndIssueIds, |
| 101 | refRegs: [ISSUE_TRACKER_RE], |
| 102 | replacer: ReplaceTrackerIssueRef, |
| 103 | }, |
| 104 | ); |
| 105 | Components.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 | ); |
| 120 | Components.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. |
| 133 | function 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 | |
| 145 | function 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. |
| 159 | function 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 | |
| 167 | function 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. |
| 184 | function 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 | |
| 210 | function 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 | |
| 224 | function 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 | |
| 250 | function 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 | |
| 266 | function 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 | |
| 280 | function 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 | |
| 318 | function 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. |
| 326 | function 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 | */ |
| 355 | function 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 | |
| 378 | function 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 | |
| 397 | function 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 | |
| 410 | function 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 | |
| 440 | export const autolink = {Components, getReferencedArtifacts, markupAutolinks}; |