Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/commands.py b/features/commands.py
new file mode 100644
index 0000000..3ba376e
--- /dev/null
+++ b/features/commands.py
@@ -0,0 +1,306 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+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)
+
+ 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 exceptions.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 if cc]
+ self.cc_remove = [_LookupMeOrUsername(cnxn, cc, services, user_id)
+ for cc in remove if cc]
+ 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 exceptions.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)
+
+ 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)