Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | // Copyright 2019 The Chromium Authors |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 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'; |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 7 | import {generateProjectIssueURL} from 'shared/helpers.js' |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 8 | |
| 9 | /* eslint-disable max-len */ |
| 10 | // When crbug links don't specify a project, the default project is Chromium. |
| 11 | const CRBUG_DEFAULT_PROJECT = 'chromium'; |
| 12 | const CRBUG_LINK_RE = /(\b(https?:\/\/)?crbug\.com\/)((\b[-a-z0-9]+)(\/))?(\d+)\b(\#c[0-9]+)?/gi; |
| 13 | const CRBUG_LINK_RE_PROJECT_GROUP = 4; |
| 14 | const CRBUG_LINK_RE_ID_GROUP = 6; |
| 15 | const CRBUG_LINK_RE_COMMENT_GROUP = 7; |
| 16 | const ISSUE_TRACKER_RE = /(\b(issues?|bugs?)[ \t]*(:|=|\b)|\bfixed[ \t]*:)([ \t]*((\b[-a-z0-9]+)[:\#])?(\#?)(\d+)\b(,?[ \t]*(and|or)?)?)+/gi; |
| 17 | const PROJECT_LOCALID_RE = /((\b(issue|bug)[ \t]*(:|=)?[ \t]*|\bfixed[ \t]*:[ \t]*)?((\b[-a-z0-9]+)[:\#])?(\#?)(\d+))/gi; |
| 18 | const PROJECT_COMMENT_BUG_RE = /(((\b(issue|bug)[ \t]*(:|=)?[ \t]*)(\#?)(\d+)[ \t*])?((\b((comment)[ \t]*(:|=)?[ \t]*(\#?))|(\B((\#))(c)))(\d+)))/gi; |
| 19 | const PROJECT_LOCALID_RE_PROJECT_GROUP = 6; |
| 20 | const PROJECT_LOCALID_RE_ID_GROUP = 8; |
| 21 | const 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. |
| 24 | const SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/|mailto:)?(go|g|shortn|who|teams)\/([^\s<]+)/gi; |
| 25 | const NUMERIC_SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/)?(b|t|o|omg|cl|cr|fxr|fxrev|fxb|tqr)\/([0-9]+)/gi; |
| 26 | const IMPLIED_LINK_RE = /(^|[^-\/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b(\/[^\s<]*)?/gi; |
| 27 | const IS_LINK_RE = /()\b(https?:\/\/|ftp:\/\/|mailto:)([^\s<]+)/gi; |
| 28 | const GIT_HASH_RE = /\b(r(evision\s+#?)?)?([a-f0-9]{40})\b/gi; |
| 29 | const SVN_REF_RE = /\b(r(evision\s+#?)?)([0-9]{4,7})\b/gi; |
| 30 | const NEW_LINE_REGEX = /^(\r\n?|\n)$/; |
| 31 | const 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. |
| 35 | const REV_NUM_GROUP = 3; |
| 36 | const LINK_TRAILING_CHARS = [ |
| 37 | [null, ':'], |
| 38 | [null, '.'], |
| 39 | [null, ','], |
| 40 | [null, '>'], |
| 41 | ['(', ')'], |
| 42 | ['[', ']'], |
| 43 | ['{', '}'], |
| 44 | ['\'', '\''], |
| 45 | ['"', '"'], |
| 46 | ]; |
| 47 | const 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 | |
| 50 | const 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. |
| 56 | Components.set( |
| 57 | '00-commentbug', |
| 58 | { |
| 59 | lookup: null, |
| 60 | extractRefs: null, |
| 61 | refRegs: [PROJECT_COMMENT_BUG_RE], |
| 62 | replacer: ReplaceCommentBugRef, |
| 63 | }, |
| 64 | ); |
| 65 | Components.set( |
| 66 | '01-tracker-crbug', |
| 67 | { |
| 68 | lookup: LookupReferencedIssues, |
| 69 | extractRefs: ExtractCrbugProjectAndIssueIds, |
| 70 | refRegs: [CRBUG_LINK_RE], |
| 71 | replacer: ReplaceCrbugIssueRef, |
| 72 | |
| 73 | }, |
| 74 | ); |
| 75 | Components.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 | ); |
| 86 | Components.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 | ); |
| 97 | Components.set( |
| 98 | '04-tracker-regular', |
| 99 | { |
| 100 | lookup: LookupReferencedIssues, |
| 101 | extractRefs: ExtractTrackerProjectAndIssueIds, |
| 102 | refRegs: [ISSUE_TRACKER_RE], |
| 103 | replacer: ReplaceTrackerIssueRef, |
| 104 | }, |
| 105 | ); |
| 106 | Components.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 | ); |
| 121 | Components.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. |
| 134 | function 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 | |
| 146 | function 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. |
| 160 | function 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 | |
| 168 | function 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. |
| 185 | function 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 | |
| 211 | function 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 | |
| 225 | function 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 | |
| 251 | function 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 | |
| 267 | function 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 | |
| 281 | function 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 | |
| 319 | function 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. |
| 327 | function createIssueRefRun(projectName, localId, summary, isClosed, content, |
| 328 | commentId) { |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 329 | const params = {'id': localId}; |
| 330 | const href = generateProjectIssueURL(projectName, '/detail', params) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 331 | return { |
| 332 | tag: 'a', |
| 333 | css: isClosed ? 'strike-through' : '', |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 334 | href: href + commentId, |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 335 | 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 | */ |
| 358 | function 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 | |
| 381 | function 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 | |
| 400 | function 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 | |
| 413 | function 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 | |
| 443 | export const autolink = {Components, getReferencedArtifacts, markupAutolinks}; |