| Index: appengine/monorail/features/commands.py
|
| diff --git a/appengine/monorail/features/commands.py b/appengine/monorail/features/commands.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..5f5ea9b08a8a29e1f9b38b887d4ddb8c2548aa5d
|
| --- /dev/null
|
| +++ b/appengine/monorail/features/commands.py
|
| @@ -0,0 +1,305 @@
|
| +# Copyright 2016 The Chromium Authors. All rights reserved.
|
| +# Use of this source code is govered by a BSD-style
|
| +# license that can be found in the LICENSE file or at
|
| +# https://developers.google.com/open-source/licenses/bsd
|
| +
|
| +"""Classes and functions that implement command-line-like issue updates."""
|
| +
|
| +import logging
|
| +import re
|
| +
|
| +from framework import framework_bizobj
|
| +from framework import framework_constants
|
| +from services import user_svc
|
| +from tracker import tracker_constants
|
| +
|
| +
|
| +def ParseQuickEditCommand(
|
| + cnxn, cmd, issue, config, logged_in_user_id, services):
|
| + """Parse a quick edit command into assignments and labels."""
|
| + parts = _BreakCommandIntoParts(cmd)
|
| + parser = AssignmentParser(None, easier_kv_labels=True)
|
| +
|
| + for key, value in parts:
|
| + if key: # A key=value assignment.
|
| + valid_assignment = parser.ParseAssignment(
|
| + cnxn, key, value, config, services, logged_in_user_id)
|
| + if not valid_assignment:
|
| + logging.info('ignoring assignment: %r, %r', key, value)
|
| +
|
| + elif value.startswith('-'): # Removing a label.
|
| + parser.labels_remove.append(_StandardizeLabel(value[1:], config))
|
| +
|
| + else: # Adding a label.
|
| + value = value.strip('+')
|
| + parser.labels_add.append(_StandardizeLabel(value, config))
|
| +
|
| + new_summary = parser.summary or issue.summary
|
| +
|
| + if parser.status is None:
|
| + new_status = issue.status
|
| + else:
|
| + new_status = parser.status
|
| +
|
| + if parser.owner_id is None:
|
| + new_owner_id = issue.owner_id
|
| + else:
|
| + new_owner_id = parser.owner_id
|
| +
|
| + new_cc_ids = [cc for cc in list(issue.cc_ids) + list(parser.cc_add)
|
| + if cc not in parser.cc_remove]
|
| + (new_labels, _update_add,
|
| + _update_remove) = framework_bizobj.MergeLabels(
|
| + issue.labels, parser.labels_add, parser.labels_remove,
|
| + config.exclusive_label_prefixes)
|
| +
|
| + return new_summary, new_status, new_owner_id, new_cc_ids, new_labels
|
| +
|
| +
|
| +ASSIGN_COMMAND_RE = re.compile(
|
| + r'(?P<key>\w+(?:-|\w)*)(?:=|:)'
|
| + r'(?:(?P<value1>(?:-|\+|\.|%|@|=|,|\w)+)|'
|
| + r'"(?P<value2>[^"]+)"|'
|
| + r"'(?P<value3>[^']+)')",
|
| + re.UNICODE | re.IGNORECASE)
|
| +
|
| +LABEL_COMMAND_RE = re.compile(
|
| + r'(?P<label>(?:\+|-)?\w(?:-|\w)*)',
|
| + re.UNICODE | re.IGNORECASE)
|
| +
|
| +
|
| +def _BreakCommandIntoParts(cmd):
|
| + """Break a quick edit command into assignment and label parts.
|
| +
|
| + Args:
|
| + cmd: string command entered by the user.
|
| +
|
| + Returns:
|
| + A list of (key, value) pairs where key is the name of the field
|
| + being assigned or None for OneWord labels, and value is the value
|
| + to assign to it, or the whole label. Value may begin with a "+"
|
| + which is just ignored, or a "-" meaning that the label should be
|
| + removed, or neither.
|
| + """
|
| + parts = []
|
| + cmd = cmd.strip()
|
| + m = True
|
| +
|
| + while m:
|
| + m = ASSIGN_COMMAND_RE.match(cmd)
|
| + if m:
|
| + key = m.group('key')
|
| + value = m.group('value1') or m.group('value2') or m.group('value3')
|
| + parts.append((key, value))
|
| + cmd = cmd[len(m.group(0)):].strip()
|
| + else:
|
| + m = LABEL_COMMAND_RE.match(cmd)
|
| + if m:
|
| + parts.append((None, m.group('label')))
|
| + cmd = cmd[len(m.group(0)):].strip()
|
| +
|
| + return parts
|
| +
|
| +
|
| +def _ParsePlusMinusList(value):
|
| + """Parse a string containing a series of plus/minuse values.
|
| +
|
| + Strings are seprated by whitespace, comma and/or semi-colon.
|
| +
|
| + Example:
|
| + value = "one +two -three"
|
| + plus = ['one', 'two']
|
| + minus = ['three']
|
| +
|
| + Args:
|
| + value: string containing unparsed plus minus values.
|
| +
|
| + Returns:
|
| + A tuple of (plus, minus) string values.
|
| + """
|
| + plus = []
|
| + minus = []
|
| + # Treat ';' and ',' as separators (in addition to SPACE)
|
| + for ch in [',', ';']:
|
| + value = value.replace(ch, ' ')
|
| + terms = [i.strip() for i in value.split()]
|
| + for item in terms:
|
| + if item.startswith('-'):
|
| + minus.append(item.lstrip('-'))
|
| + else:
|
| + plus.append(item.lstrip('+')) # optional leading '+'
|
| +
|
| + return plus, minus
|
| +
|
| +
|
| +class AssignmentParser(object):
|
| + """Class to parse assignment statements in quick edits or email replies."""
|
| +
|
| + def __init__(self, template, easier_kv_labels=False):
|
| + self.cc_list = []
|
| + self.cc_add = []
|
| + self.cc_remove = []
|
| + self.owner_id = None
|
| + self.status = None
|
| + self.summary = None
|
| + self.labels_list = []
|
| + self.labels_add = []
|
| + self.labels_remove = []
|
| + self.branch = None
|
| +
|
| + # Accept "Anything=Anything" for quick-edit, but not in commit-log-commands
|
| + # because it would be too error-prone when mixed with plain text comment
|
| + # text and without autocomplete to help users triggering it via typos.
|
| + self.easier_kv_labels = easier_kv_labels
|
| +
|
| + if template:
|
| + if template.owner_id:
|
| + self.owner_id = template.owner_id
|
| + if template.summary:
|
| + self.summary = template.summary
|
| + if template.labels:
|
| + self.labels_list = template.labels
|
| + # Do not have a similar check as above for status because it could be an
|
| + # empty string.
|
| + self.status = template.status
|
| +
|
| + def ParseAssignment(self, cnxn, key, value, config, services, user_id):
|
| + """Parse command-style text entered by the user to update an issue.
|
| +
|
| + E.g., The user may want to set the issue status to "reviewed", or
|
| + set the owner to "me".
|
| +
|
| + Args:
|
| + cnxn: connection to SQL database.
|
| + key: string name of the field to set.
|
| + value: string value to be interpreted.
|
| + config: Projects' issue tracker configuration PB.
|
| + services: connections to backends.
|
| + user_id: int user ID of the user making the change.
|
| +
|
| + Returns:
|
| + True if the line could be parsed as an assigment, False otherwise.
|
| + Also, as a side-effect, the assigned values are built up in the instance
|
| + variables of the parser.
|
| + """
|
| + valid_line = True
|
| +
|
| + if key == 'owner':
|
| + if framework_constants.NO_VALUE_RE.match(value):
|
| + self.owner_id = framework_constants.NO_USER_SPECIFIED
|
| + else:
|
| + try:
|
| + self.owner_id = _LookupMeOrUsername(cnxn, value, services, user_id)
|
| + except user_svc.NoSuchUserException:
|
| + logging.warning('bad owner: %r when committing to project_id %r',
|
| + value, config.project_id)
|
| + valid_line = False
|
| +
|
| + elif key == 'cc':
|
| + try:
|
| + add, remove = _ParsePlusMinusList(value)
|
| + self.cc_add = [_LookupMeOrUsername(cnxn, cc, services, user_id)
|
| + for cc in add]
|
| + self.cc_remove = [_LookupMeOrUsername(cnxn, cc, services, user_id)
|
| + for cc in remove]
|
| + for user_id in self.cc_add:
|
| + if user_id not in self.cc_list:
|
| + self.cc_list.append(user_id)
|
| + self.cc_list = [user_id for user_id in self.cc_list
|
| + if user_id not in self.cc_remove]
|
| + except user_svc.NoSuchUserException:
|
| + logging.warning('bad cc: %r when committing to project_id %r',
|
| + value, config.project_id)
|
| + valid_line = False
|
| +
|
| + elif key == 'summary':
|
| + self.summary = value
|
| +
|
| + elif key == 'status':
|
| + if framework_constants.NO_VALUE_RE.match(value):
|
| + self.status = ''
|
| + else:
|
| + self.status = _StandardizeStatus(value, config)
|
| +
|
| + elif key == 'label' or key == 'labels':
|
| + self.labels_add, self.labels_remove = _ParsePlusMinusList(value)
|
| + self.labels_add = [_StandardizeLabel(lab, config)
|
| + for lab in self.labels_add]
|
| + self.labels_remove = [_StandardizeLabel(lab, config)
|
| + for lab in self.labels_remove]
|
| + (self.labels_list, _update_add,
|
| + _update_remove) = framework_bizobj.MergeLabels(
|
| + self.labels_list, self.labels_add, self.labels_remove,
|
| + config.exclusive_label_prefixes)
|
| +
|
| + elif (self.easier_kv_labels and
|
| + key not in tracker_constants.RESERVED_PREFIXES and
|
| + key and value):
|
| + if key.startswith('-'):
|
| + self.labels_remove.append(_StandardizeLabel(
|
| + '%s-%s' % (key[1:], value), config))
|
| + else:
|
| + self.labels_add.append(_StandardizeLabel(
|
| + '%s-%s' % (key, value), config))
|
| +
|
| + else:
|
| + valid_line = False
|
| +
|
| + return valid_line
|
| +
|
| +
|
| +def _StandardizeStatus(status, config):
|
| + """Attempt to match a user-supplied status with standard status values.
|
| +
|
| + Args:
|
| + status: User-supplied status string.
|
| + config: Project's issue tracker configuration PB.
|
| +
|
| + Returns:
|
| + A canonicalized status string, that matches a standard project
|
| + value, if found.
|
| + """
|
| + well_known_statuses = [wks.status for wks in config.well_known_statuses]
|
| + return _StandardizeArtifact(status, well_known_statuses)
|
| +
|
| +
|
| +def _StandardizeLabel(label, config):
|
| + """Attempt to match a user-supplied label with standard label values.
|
| +
|
| + Args:
|
| + label: User-supplied label string.
|
| + config: Project's issue tracker configuration PB.
|
| +
|
| + Returns:
|
| + A canonicalized label string, that matches a standard project
|
| + value, if found.
|
| + """
|
| + well_known_labels = [wkl.label for wkl in config.well_known_labels]
|
| + return _StandardizeArtifact(label, well_known_labels)
|
| +
|
| +
|
| +def _StandardizeArtifact(artifact, well_known_artifacts):
|
| + """Attempt to match a user-supplied artifact with standard artifact values.
|
| +
|
| + Args:
|
| + artifact: User-supplied artifact string.
|
| + well_known_artifacts: List of well known values of the artifact.
|
| +
|
| + Returns:
|
| + A canonicalized artifact string, that matches a standard project
|
| + value, if found.
|
| + """
|
| + artifact = framework_bizobj.CanonicalizeLabel(artifact)
|
| + for wka in well_known_artifacts:
|
| + if artifact.lower() == wka.lower():
|
| + return wka
|
| + # No match - use user-supplied artifact.
|
| + return artifact
|
| +
|
| +
|
| +def _LookupMeOrUsername(cnxn, username, services, user_id):
|
| + """Handle the 'me' syntax or lookup a user's user ID."""
|
| + if username.lower() == 'me':
|
| + return user_id
|
| +
|
| + return services.user.LookupUserID(cnxn, username)
|
|
|