blob: 9d9611f86ec11321ed8e29ffe37abeabecfcc38c [file] [log] [blame]
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Autolink helps auto-link references to artifacts in text.
This class maintains a registry of artifact autolink syntax specs and
callbacks. The structure of that registry is:
{ component_name: (lookup_callback, match_to_reference_function,
{ regex: substitution_callback, ...}),
...
}
For example:
{ 'tracker':
(GetReferencedIssues,
ExtractProjectAndIssueIds,
{_ISSUE_REF_RE: ReplaceIssueRef}),
'versioncontrol':
(GetReferencedRevisions,
ExtractProjectAndRevNum,
{_GIT_HASH_RE: ReplaceRevisionRef}),
}
The dictionary of regexes is used here because, in the future, we
might add more regexes for each component rather than have one complex
regex per component.
"""
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import logging
import re
from six.moves import urllib
from six.moves.urllib.parse import urlparse
import settings
from features import autolink_constants
from framework import template_helpers
from framework import validate
from mrproto import project_pb2
from tracker import tracker_helpers
# If the total length of all comments is too large, we don't autolink.
_MAX_TOTAL_LENGTH = 150 * 1024 # 150KB
# Special all_referenced_artifacts value used to indicate that the
# text content is too big to lookup all referenced artifacts quickly.
SKIP_LOOKUPS = 'skip lookups'
_CLOSING_TAG_RE = re.compile('</[a-z0-9]+>$', re.IGNORECASE)
# These are allowed in links, but if any of closing delimiters appear
# at the end of the link, and the opening one is not part of the link,
# then trim off the closing delimiters.
_LINK_TRAILING_CHARS = [
(None, ':'),
(None, '.'),
(None, ','),
('(', ')'),
('[', ']'),
('{', '}'),
('<', '>'),
("'", "'"),
('"', '"'),
]
def LinkifyEmail(_mr, autolink_regex_match, component_ref_artifacts):
"""Examine a textual reference and replace it with a hyperlink or not.
This is a callback for use with the autolink feature. The function
parameters are standard for this type of callback.
Args:
_mr: unused information parsed from the HTTP request.
autolink_regex_match: regex match for the textual reference.
component_ref_artifacts: result of call to GetReferencedUsers.
Returns:
A list of TextRuns with tag=a linking to the user profile page of
any defined users, otherwise a mailto: link is generated.
"""
email = autolink_regex_match.group(0)
if not validate.IsValidEmail(email):
return [template_helpers.TextRun(email)]
if component_ref_artifacts and email in component_ref_artifacts:
href = '/u/%s' % email
else:
href = 'mailto:' + email
result = [template_helpers.TextRun(email, tag='a', href=href)]
return result
def CurryGetReferencedUsers(services):
"""Return a function to get ref'd users with these services objects bound.
Currying is a convienent way to give the callback access to the services
objects, but without requiring that all possible services objects be passed
through the autolink registry and functions.
Args:
services: connection to the user persistence layer.
Returns:
A ready-to-use function that accepts the arguments that autolink
expects to pass to it.
"""
def GetReferencedUsers(mr, emails):
"""Return a dict of users referenced by these comments.
Args:
mr: commonly used info parsed from the request.
ref_tuples: email address strings for each user
that is mentioned in the comment text.
Returns:
A dictionary {email: user_pb} including all existing users.
"""
user_id_dict = services.user.LookupExistingUserIDs(mr.cnxn, emails)
users_by_id = services.user.GetUsersByIDs(mr.cnxn,
list(user_id_dict.values()))
users_by_email = {
email: users_by_id[user_id]
for email, user_id in user_id_dict.items()}
return users_by_email
return GetReferencedUsers
def Linkify(_mr, autolink_regex_match, _component_ref_artifacts):
"""Examine a textual reference and replace it with a hyperlink or not.
This is a callback for use with the autolink feature. The function
parameters are standard for this type of callback.
Args:
_mr: unused information parsed from the HTTP request.
autolink_regex_match: regex match for the textual reference.
_component_ref_artifacts: unused result of call to GetReferencedIssues.
Returns:
A list of TextRuns with tag=a for all matched ftp, http, https and mailto
links converted into HTML hyperlinks.
"""
hyperlink = autolink_regex_match.group(0)
trailing = ''
for begin, end in _LINK_TRAILING_CHARS:
if hyperlink.endswith(end):
if not begin or hyperlink[:-len(end)].find(begin) == -1:
trailing = end + trailing
hyperlink = hyperlink[:-len(end)]
tag_match = _CLOSING_TAG_RE.search(hyperlink)
if tag_match:
trailing = hyperlink[tag_match.start(0):] + trailing
hyperlink = hyperlink[:tag_match.start(0)]
href = hyperlink
if not href.lower().startswith(('http', 'ftp', 'mailto')):
# We use http because redirects for https are not all set up.
href = 'http://' + href
if (not validate.IsValidURL(href) and
not (href.startswith('mailto') and validate.IsValidEmail(href[7:]))):
return [template_helpers.TextRun(autolink_regex_match.group(0))]
result = [template_helpers.TextRun(hyperlink, tag='a', href=href)]
if trailing:
result.append(template_helpers.TextRun(trailing))
return result
# Regular expression to detect git hashes.
# Used to auto-link to Git hashes on crrev.com when displaying issue details.
# Matches "rN", "r#N", and "revision N" when "rN" is not part of a larger word
# and N is a hexadecimal string of 40 chars.
_GIT_HASH_RE = re.compile(
r'\b(?P<prefix>r(evision\s+#?)?)?(?P<revnum>([a-f0-9]{40}))\b',
re.IGNORECASE | re.MULTILINE)
# This is for SVN revisions and Git commit posisitons.
_SVN_REF_RE = re.compile(
r'\b(?P<prefix>r(evision\s+#?)?)(?P<revnum>([0-9]{4,7}))\b',
re.IGNORECASE | re.MULTILINE)
def GetReferencedRevisions(_mr, _refs):
"""Load the referenced revision objects."""
# For now we just autolink any revision hash without actually
# checking that such a revision exists,
# TODO(jrobbins): Hit crrev.com and check that the revision exists
# and show a rollover with revision info.
return None
def ExtractRevNums(_mr, autolink_regex_match):
"""Return internal representation of a rev reference."""
ref = autolink_regex_match.group('revnum')
logging.debug('revision ref = %s', ref)
return [ref]
def ReplaceRevisionRef(
mr, autolink_regex_match, _component_ref_artifacts):
"""Return HTML markup for an autolink reference."""
prefix = autolink_regex_match.group('prefix')
revnum = autolink_regex_match.group('revnum')
url = _GetRevisionURLFormat(mr.project).format(revnum=revnum)
content = revnum
if prefix:
content = '%s%s' % (prefix, revnum)
return [template_helpers.TextRun(content, tag='a', href=url)]
def _GetRevisionURLFormat(project):
# TODO(jrobbins): Expose a UI to customize it to point to whatever site
# hosts the source code. Also, site-wide default.
return (project.revision_url_format or settings.revision_url_format)
# Regular expression to detect issue references.
# Used to auto-link to other issues when displaying issue details.
# Matches "issue " when "issue" is not part of a larger word, or
# "issue #", or just a "#" when it is preceeded by a space.
_ISSUE_REF_RE = re.compile(r"""
(?P<prefix>\b(issues?|bugs?)[ \t]*(:|=)?)
([ \t]*(?P<project_name>\b[-a-z0-9]+[:\#])?
(?P<number_sign>\#?)
(?P<local_id>\d+)\b
(,?[ \t]*(and|or)?)?)+""", re.IGNORECASE | re.VERBOSE)
# This is for chromium.org's crbug.com shorthand domain.
_CRBUG_REF_RE = re.compile(r"""
(?P<prefix>\b(https?://)?crbug.com/)
((?P<project_name>\b[-a-z0-9]+)(?P<separator>/))?
(?P<local_id>\d+)\b
(?P<anchor>\#c[0-9]+)?""", re.IGNORECASE | re.VERBOSE)
# Once the overall issue reference has been detected, pick out the specific
# issue project:id items within it. Often there is just one, but the "and|or"
# syntax can allow multiple issues.
_SINGLE_ISSUE_REF_RE = re.compile(r"""
(?P<prefix>\b(issue|bug)[ \t]*)?
(?P<project_name>\b[-a-z0-9]+[:\#])?
(?P<number_sign>\#?)
(?P<local_id>\d+)\b""", re.IGNORECASE | re.VERBOSE)
def CurryGetReferencedIssues(services):
"""Return a function to get ref'd issues with these services objects bound.
Currying is a convienent way to give the callback access to the services
objects, but without requiring that all possible services objects be passed
through the autolink registry and functions.
Args:
services: connection to issue, config, and project persistence layers.
Returns:
A ready-to-use function that accepts the arguments that autolink
expects to pass to it.
"""
def GetReferencedIssues(mr, ref_tuples):
"""Return lists of open and closed issues referenced by these comments.
Args:
mr: commonly used info parsed from the request.
ref_tuples: list of (project_name, local_id) tuples for each issue
that is mentioned in the comment text. The project_name may be None,
in which case the issue is assumed to be in the current project.
Returns:
A list of open and closed issue dicts.
"""
ref_projects = services.project.GetProjectsByName(
mr.cnxn,
[(ref_pn or mr.project_name) for ref_pn, _ in ref_tuples])
issue_ids, _misses = services.issue.ResolveIssueRefs(
mr.cnxn, ref_projects, mr.project_name, ref_tuples)
open_issues, closed_issues = (
tracker_helpers.GetAllowedOpenedAndClosedIssues(
mr, issue_ids, services))
open_dict = {}
for issue in open_issues:
open_dict[_IssueProjectKey(issue.project_name, issue.local_id)] = issue
closed_dict = {}
for issue in closed_issues:
closed_dict[_IssueProjectKey(issue.project_name, issue.local_id)] = issue
logging.info('autolinking dicts %r and %r', open_dict, closed_dict)
return open_dict, closed_dict
return GetReferencedIssues
def _ParseProjectNameMatch(project_name):
"""Process the passed project name and determine the best representation.
Args:
project_name: a string with the project name matched in a regex
Returns:
A minimal representation of the project name, None if no valid content.
"""
if not project_name:
return None
return project_name.lstrip().rstrip('#: \t\n')
def _ExtractProjectAndIssueIds(
autolink_regex_match, subregex, default_project_name=None):
"""Convert a regex match for a textual reference into our internal form."""
whole_str = autolink_regex_match.group(0)
refs = []
for submatch in subregex.finditer(whole_str):
project_name = (
_ParseProjectNameMatch(submatch.group('project_name')) or
default_project_name)
ref = (project_name, int(submatch.group('local_id')))
refs.append(ref)
logging.info('issue ref = %s', ref)
return refs
def ExtractProjectAndIssueIdsNormal(_mr, autolink_regex_match):
"""Convert a regex match for a textual reference into our internal form."""
return _ExtractProjectAndIssueIds(
autolink_regex_match, _SINGLE_ISSUE_REF_RE)
def ExtractProjectAndIssueIdsCrBug(_mr, autolink_regex_match):
"""Convert a regex match for a textual reference into our internal form."""
return _ExtractProjectAndIssueIds(
autolink_regex_match, _CRBUG_REF_RE, default_project_name='chromium')
# This uses project name to avoid a lookup on project ID in a function
# that has no services object.
def _IssueProjectKey(project_name, local_id):
"""Make a dictionary key to identify a referenced issue."""
return '%s:%d' % (project_name, local_id)
class IssueRefRun(object):
"""A text run that links to a referenced issue."""
def __init__(self, issue, is_closed, project_name, content, anchor):
self.tag = 'a'
self.css_class = 'closed_ref' if is_closed else None
self.title = issue.summary
self.href = '/p/%s/issues/detail?id=%d%s' % (
project_name, issue.local_id, anchor)
self.content = content
if is_closed:
self.content = ' %s ' % self.content
def _ReplaceIssueRef(
autolink_regex_match, component_ref_artifacts, single_issue_regex,
default_project_name):
"""Examine a textual reference and replace it with an autolink or not.
Args:
autolink_regex_match: regex match for the textual reference.
component_ref_artifacts: result of earlier call to GetReferencedIssues.
single_issue_regex: regular expression to parse individual issue references
out of a multi-issue-reference phrase. E.g., "issues 12 and 34".
default_project_name: project name to use when not specified.
Returns:
A list of IssueRefRuns and TextRuns to replace the textual
reference. If there is an issue to autolink to, we return an HTML
hyperlink. Otherwise, we the run will have the original plain
text.
"""
open_dict, closed_dict = {}, {}
if component_ref_artifacts:
open_dict, closed_dict = component_ref_artifacts
original = autolink_regex_match.group(0)
logging.info('called ReplaceIssueRef on %r', original)
result_runs = []
pos = 0
for submatch in single_issue_regex.finditer(original):
if submatch.start() >= pos:
if original[pos: submatch.start()]:
result_runs.append(template_helpers.TextRun(
original[pos: submatch.start()]))
replacement_run = _ReplaceSingleIssueRef(
submatch, open_dict, closed_dict, default_project_name)
result_runs.append(replacement_run)
pos = submatch.end()
if original[pos:]:
result_runs.append(template_helpers.TextRun(original[pos:]))
return result_runs
def ReplaceIssueRefNormal(mr, autolink_regex_match, component_ref_artifacts):
"""Replaces occurances of 'issue 123' with link TextRuns as needed."""
return _ReplaceIssueRef(
autolink_regex_match, component_ref_artifacts,
_SINGLE_ISSUE_REF_RE, mr.project_name)
def ReplaceIssueRefCrBug(_mr, autolink_regex_match, component_ref_artifacts):
"""Replaces occurances of 'crbug.com/123' with link TextRuns as needed."""
return _ReplaceIssueRef(
autolink_regex_match, component_ref_artifacts,
_CRBUG_REF_RE, 'chromium')
def _ReplaceSingleIssueRef(
submatch, open_dict, closed_dict, default_project_name):
"""Replace one issue reference with a link, or the original text."""
content = submatch.group(0)
project_name = submatch.group('project_name')
anchor = submatch.groupdict().get('anchor') or ''
if project_name:
project_name = project_name.lstrip().rstrip(':#')
else:
# We need project_name for the URL, even if it is not in the text.
project_name = default_project_name
local_id = int(submatch.group('local_id'))
issue_key = _IssueProjectKey(project_name, local_id)
if issue_key in open_dict:
return IssueRefRun(
open_dict[issue_key], False, project_name, content, anchor)
elif issue_key in closed_dict:
return IssueRefRun(
closed_dict[issue_key], True, project_name, content, anchor)
else: # Don't link to non-existent issues.
return template_helpers.TextRun(content)
class Autolink(object):
"""Maintains a registry of autolink syntax and can apply it to comments."""
def __init__(self):
self.registry = {}
def RegisterComponent(self, component_name, artifact_lookup_function,
match_to_reference_function, autolink_re_subst_dict):
"""Register all the autolink info for a software component.
Args:
component_name: string name of software component, must be unique.
artifact_lookup_function: function to batch lookup all artifacts that
might have been referenced in a set of comments:
function(all_matches) -> referenced_artifacts
the referenced_artifacts will be pased to each subst function.
match_to_reference_function: convert a regex match object to
some internal representation of the artifact reference.
autolink_re_subst_dict: dictionary of regular expressions and
the substitution function that should be called for each match:
function(match, referenced_artifacts) -> replacement_markup
"""
self.registry[component_name] = (artifact_lookup_function,
match_to_reference_function,
autolink_re_subst_dict)
def GetAllReferencedArtifacts(
self, mr, comment_text_list, max_total_length=_MAX_TOTAL_LENGTH):
"""Call callbacks to lookup all artifacts possibly referenced.
Args:
mr: information parsed out of the user HTTP request.
comment_text_list: list of comment content strings.
max_total_length: int max number of characters to accept:
if more than this, then skip autolinking entirely.
Returns:
Opaque object that can be pased to MarkupAutolinks. It's
structure happens to be {component_name: artifact_list, ...},
or the special value SKIP_LOOKUPS.
"""
total_len = sum(len(comment_text) for comment_text in comment_text_list)
if total_len > max_total_length:
return SKIP_LOOKUPS
all_referenced_artifacts = {}
for comp, (lookup, match_to_refs, re_dict) in self.registry.items():
refs = set()
for comment_text in comment_text_list:
for regex in re_dict:
for match in regex.finditer(comment_text):
additional_refs = match_to_refs(mr, match)
if additional_refs:
refs.update(additional_refs)
all_referenced_artifacts[comp] = lookup(mr, refs)
return all_referenced_artifacts
def MarkupAutolinks(self, mr, text_runs, all_referenced_artifacts):
"""Loop over components and regexes, applying all substitutions.
Args:
mr: info parsed from the user's HTTP request.
text_runs: List of text runs for the user's comment.
all_referenced_artifacts: result of previous call to
GetAllReferencedArtifacts.
Returns:
List of text runs for the entire user comment, some of which may have
attribures that cause them to render as links in render-rich-text.ezt.
"""
items = list(self.registry.items())
items.sort() # Process components in determinate alphabetical order.
for component, (_lookup, _match_ref, re_subst_dict) in items:
if all_referenced_artifacts == SKIP_LOOKUPS:
component_ref_artifacts = None
else:
component_ref_artifacts = all_referenced_artifacts[component]
for regex, subst_fun in re_subst_dict.items():
text_runs = self._ApplySubstFunctionToRuns(
text_runs, regex, subst_fun, mr, component_ref_artifacts)
return text_runs
def _ApplySubstFunctionToRuns(
self, text_runs, regex, subst_fun, mr, component_ref_artifacts):
"""Apply autolink regex and substitution function to each text run.
Args:
text_runs: list of TextRun objects with parts of the original comment.
regex: Regular expression for detecting textual references to artifacts.
subst_fun: function to return autolink markup, or original text.
mr: common info parsed from the user HTTP request.
component_ref_artifacts: already-looked-up destination artifacts to use
when computing substitution text.
Returns:
A new list with more and smaller runs, some of which may have tag
and link attributes set.
"""
result_runs = []
for run in text_runs:
content = run.content
if run.tag:
# This chunk has already been substituted, don't allow nested
# autolinking to mess up our output.
result_runs.append(run)
else:
pos = 0
for match in regex.finditer(content):
if match.start() > pos:
result_runs.append(template_helpers.TextRun(
content[pos: match.start()]))
replacement_runs = subst_fun(mr, match, component_ref_artifacts)
result_runs.extend(replacement_runs)
pos = match.end()
if run.content[pos:]: # Keep any text that came after the last match
result_runs.append(template_helpers.TextRun(run.content[pos:]))
# TODO(jrobbins): ideally we would merge consecutive plain text runs
# so that regexes can match across those run boundaries.
return result_runs
def RegisterAutolink(services):
"""Register all the autolink hooks."""
# The order of the RegisterComponent() calls does not matter so that we could
# do this registration from separate modules in the future if needed.
# Priority order of application is determined by the names of the registered
# handers, which are sorted in MarkupAutolinks().
services.autolink.RegisterComponent(
'01-tracker-crbug',
CurryGetReferencedIssues(services),
ExtractProjectAndIssueIdsCrBug,
{_CRBUG_REF_RE: ReplaceIssueRefCrBug})
services.autolink.RegisterComponent(
'02-linkify-full-urls',
lambda request, mr: None,
lambda mr, match: None,
{autolink_constants.IS_A_LINK_RE: Linkify})
services.autolink.RegisterComponent(
'03-linkify-user-profiles-or-mailto',
CurryGetReferencedUsers(services),
lambda _mr, match: [match.group(0)],
{autolink_constants.IS_IMPLIED_EMAIL_RE: LinkifyEmail})
services.autolink.RegisterComponent(
'04-tracker-regular',
CurryGetReferencedIssues(services),
ExtractProjectAndIssueIdsNormal,
{_ISSUE_REF_RE: ReplaceIssueRefNormal})
services.autolink.RegisterComponent(
'05-linkify-shorthand',
lambda request, mr: None,
lambda mr, match: None,
{autolink_constants.IS_A_SHORT_LINK_RE: Linkify,
autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE: Linkify,
autolink_constants.IS_IMPLIED_LINK_RE: Linkify,
})
services.autolink.RegisterComponent(
'06-versioncontrol',
GetReferencedRevisions,
ExtractRevNums,
{_GIT_HASH_RE: ReplaceRevisionRef,
_SVN_REF_RE: ReplaceRevisionRef})