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) |