Index: appengine/monorail/features/commitlogcommands.py |
diff --git a/appengine/monorail/features/commitlogcommands.py b/appengine/monorail/features/commitlogcommands.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..16c6e3de48034a2b75ed3fde59ec64221a8531cb |
--- /dev/null |
+++ b/appengine/monorail/features/commitlogcommands.py |
@@ -0,0 +1,155 @@ |
+# 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 |
+ |
+"""Implements processing of issue update command lines. |
+ |
+This currently processes the leading command-lines that appear |
+at the top of inbound email messages to update existing issues. |
+ |
+It could also be expanded to allow new issues to be created. Or, to |
+handle commands in commit-log messages if the version control system |
+invokes a webhook. |
+""" |
+ |
+import logging |
+import re |
+ |
+from features import commands |
+from features import notify |
+from framework import emailfmt |
+from framework import framework_bizobj |
+from framework import framework_helpers |
+from services import issue_svc |
+ |
+ |
+# Actions have separate 'Parse' and 'Run' implementations to allow better |
+# testing coverage. |
+class IssueAction(object): |
+ """Base class for all issue commands.""" |
+ |
+ def __init__(self): |
+ self.parser = commands.AssignmentParser(None) |
+ self.description = '' |
+ self.inbound_message = None |
+ self.commenter_id = None |
+ self.project = None |
+ self.config = None |
+ self.hostport = framework_helpers.GetHostPort() |
+ |
+ def Parse( |
+ self, cnxn, project_name, commenter_id, lines, services, |
+ strip_quoted_lines=False, hostport=None): |
+ """Populate object from raw user input.""" |
+ self.project = services.project.GetProjectByName(cnxn, project_name) |
+ self.config = services.config.GetProjectConfig( |
+ cnxn, self.project.project_id) |
+ self.commenter_id = commenter_id |
+ |
+ # Process all valid key-value lines. Once we find a non key-value line, |
+ # treat the rest as the 'description'. |
+ for idx, line in enumerate(lines): |
+ valid_line = False |
+ m = re.match(r'^\s*(\w+)\s*\:\s*(.*?)\s*$', line) |
+ if m: |
+ # Process Key-Value |
+ key = m.group(1).lower() |
+ value = m.group(2) |
+ valid_line = self.parser.ParseAssignment( |
+ cnxn, key, value, self.config, services, self.commenter_id) |
+ |
+ if not valid_line: |
+ # Not Key-Value. Treat this line and remaining as 'description'. |
+ # First strip off any trailing blank lines. |
+ while lines and not lines[-1].strip(): |
+ lines.pop() |
+ if lines: |
+ self.description = '\n'.join(lines[idx:]) |
+ break |
+ |
+ if strip_quoted_lines: |
+ self.inbound_message = '\n'.join(lines) |
+ self.description = emailfmt.StripQuotedText(self.description) |
+ |
+ if hostport: |
+ self.hostport = hostport |
+ |
+ for key in ['owner_id', 'cc_add', 'cc_remove', 'summary', |
+ 'status', 'labels_add', 'labels_remove', 'branch']: |
+ logging.info('\t%s: %s', key, self.parser.__dict__[key]) |
+ |
+ for key in ['commenter_id', 'description', 'hostport']: |
+ logging.info('\t%s: %s', key, self.__dict__[key]) |
+ |
+ def Run(self, cnxn, services, allow_edit=True): |
+ """Execute this action.""" |
+ raise NotImplementedError() |
+ |
+ |
+class UpdateIssueAction(IssueAction): |
+ """Implements processing email replies or the "update issue" command.""" |
+ |
+ def __init__(self, local_id): |
+ super(UpdateIssueAction, self).__init__() |
+ self.local_id = local_id |
+ |
+ def Run(self, cnxn, services, allow_edit=True): |
+ """Updates an issue based on the parsed commands.""" |
+ try: |
+ issue = services.issue.GetIssueByLocalID( |
+ cnxn, self.project.project_id, self.local_id) |
+ except issue_svc.NoSuchIssueException: |
+ return # Issue does not exist, so do nothing |
+ |
+ old_owner_id = issue.owner_id |
+ new_summary = self.parser.summary or issue.summary |
+ |
+ if self.parser.status is None: |
+ new_status = issue.status |
+ else: |
+ new_status = self.parser.status |
+ |
+ if self.parser.owner_id is None: |
+ new_owner_id = issue.owner_id |
+ else: |
+ new_owner_id = self.parser.owner_id |
+ |
+ new_cc_ids = [cc for cc in list(issue.cc_ids) + list(self.parser.cc_add) |
+ if cc not in self.parser.cc_remove] |
+ (new_labels, _update_add, |
+ _update_remove) = framework_bizobj.MergeLabels( |
+ issue.labels, self.parser.labels_add, |
+ self.parser.labels_remove, |
+ self.config.exclusive_label_prefixes) |
+ |
+ new_field_values = issue.field_values # TODO(jrobbins): edit custom ones |
+ |
+ if not allow_edit: |
+ # If user can't edit, then only consider the plain-text comment, |
+ # and set all other fields back to their original values. |
+ logging.info('Processed reply from user who can not edit issue') |
+ new_summary = issue.summary |
+ new_status = issue.status |
+ new_owner_id = issue.owner_id |
+ new_cc_ids = issue.cc_ids |
+ new_labels = issue.labels |
+ new_field_values = issue.field_values |
+ |
+ amendments, _comment_pb = services.issue.ApplyIssueComment( |
+ cnxn, services, self.commenter_id, |
+ self.project.project_id, issue.local_id, new_summary, new_status, |
+ new_owner_id, new_cc_ids, new_labels, new_field_values, |
+ issue.component_ids, issue.blocked_on_iids, issue.blocking_iids, |
+ issue.dangling_blocked_on_refs, issue.dangling_blocking_refs, |
+ issue.merged_into, comment=self.description, |
+ inbound_message=self.inbound_message) |
+ |
+ logging.info('Updated issue %s:%s w/ amendments %r', |
+ self.project.project_name, issue.local_id, amendments) |
+ |
+ if amendments or self.description: # Avoid completely empty comments. |
+ cmnts = services.issue.GetCommentsForIssue(cnxn, issue.issue_id) |
+ notify.PrepareAndSendIssueChangeNotification( |
+ self.project.project_id, self.local_id, self.hostport, |
+ self.commenter_id, len(cmnts) - 1, old_owner_id=old_owner_id) |