Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style |
| 3 | # license that can be found in the LICENSE file or at |
| 4 | # https://developers.google.com/open-source/licenses/bsd |
| 5 | |
| 6 | """Implements processing of issue update command lines. |
| 7 | |
| 8 | This currently processes the leading command-lines that appear |
| 9 | at the top of inbound email messages to update existing issues. |
| 10 | |
| 11 | It could also be expanded to allow new issues to be created. Or, to |
| 12 | handle commands in commit-log messages if the version control system |
| 13 | invokes a webhook. |
| 14 | """ |
| 15 | from __future__ import print_function |
| 16 | from __future__ import division |
| 17 | from __future__ import absolute_import |
| 18 | |
| 19 | import logging |
| 20 | import re |
| 21 | |
| 22 | from businesslogic import work_env |
| 23 | from features import commands |
| 24 | from features import send_notifications |
| 25 | from framework import emailfmt |
| 26 | from framework import exceptions |
| 27 | from framework import framework_bizobj |
| 28 | from framework import framework_helpers |
| 29 | from framework import permissions |
| 30 | from proto import tracker_pb2 |
| 31 | |
| 32 | |
| 33 | # Actions have separate 'Parse' and 'Run' implementations to allow better |
| 34 | # testing coverage. |
| 35 | class IssueAction(object): |
| 36 | """Base class for all issue commands.""" |
| 37 | |
| 38 | def __init__(self): |
| 39 | self.parser = commands.AssignmentParser(None) |
| 40 | self.description = '' |
| 41 | self.inbound_message = None |
| 42 | self.commenter_id = None |
| 43 | self.project = None |
| 44 | self.config = None |
| 45 | self.hostport = framework_helpers.GetHostPort() |
| 46 | |
| 47 | def Parse( |
| 48 | self, cnxn, project_name, commenter_id, lines, services, |
| 49 | strip_quoted_lines=False, hostport=None): |
| 50 | """Populate object from raw user input. |
| 51 | |
| 52 | Args: |
| 53 | cnxn: connection to SQL database. |
| 54 | project_name: Name of the project containing the issue. |
| 55 | commenter_id: int user ID of user creating comment. |
| 56 | lines: list of strings containing test to be parsed. |
| 57 | services: References to existing objects from Monorail's service layer. |
| 58 | strip_quoted_lines: boolean for whether to remove quoted lines from text. |
| 59 | hostport: Optionally override the current instance's hostport variable. |
| 60 | |
| 61 | Returns: |
| 62 | A boolean for whether any command lines were found while parsing. |
| 63 | |
| 64 | Side-effect: |
| 65 | Edits the values of instance variables in this class with parsing output. |
| 66 | """ |
| 67 | self.project = services.project.GetProjectByName(cnxn, project_name) |
| 68 | self.config = services.config.GetProjectConfig( |
| 69 | cnxn, self.project.project_id) |
| 70 | self.commenter_id = commenter_id |
| 71 | |
| 72 | has_commands = False |
| 73 | |
| 74 | # Process all valid key-value lines. Once we find a non key-value line, |
| 75 | # treat the rest as the 'description'. |
| 76 | for idx, line in enumerate(lines): |
| 77 | valid_line = False |
| 78 | m = re.match(r'^\s*(\w+)\s*\:\s*(.*?)\s*$', line) |
| 79 | if m: |
| 80 | has_commands = True |
| 81 | # Process Key-Value |
| 82 | key = m.group(1).lower() |
| 83 | value = m.group(2) |
| 84 | valid_line = self.parser.ParseAssignment( |
| 85 | cnxn, key, value, self.config, services, self.commenter_id) |
| 86 | |
| 87 | if not valid_line: |
| 88 | # Not Key-Value. Treat this line and remaining as 'description'. |
| 89 | # First strip off any trailing blank lines. |
| 90 | while lines and not lines[-1].strip(): |
| 91 | lines.pop() |
| 92 | if lines: |
| 93 | self.description = '\n'.join(lines[idx:]) |
| 94 | break |
| 95 | |
| 96 | if strip_quoted_lines: |
| 97 | self.inbound_message = '\n'.join(lines) |
| 98 | self.description = emailfmt.StripQuotedText(self.description) |
| 99 | |
| 100 | if hostport: |
| 101 | self.hostport = hostport |
| 102 | |
| 103 | for key in ['owner_id', 'cc_add', 'cc_remove', 'summary', |
| 104 | 'status', 'labels_add', 'labels_remove', 'branch']: |
| 105 | logging.info('\t%s: %s', key, self.parser.__dict__[key]) |
| 106 | |
| 107 | for key in ['commenter_id', 'description', 'hostport']: |
| 108 | logging.info('\t%s: %s', key, self.__dict__[key]) |
| 109 | |
| 110 | return has_commands |
| 111 | |
| 112 | def Run(self, mc, services): |
| 113 | """Execute this action.""" |
| 114 | raise NotImplementedError() |
| 115 | |
| 116 | |
| 117 | class UpdateIssueAction(IssueAction): |
| 118 | """Implements processing email replies or the "update issue" command.""" |
| 119 | |
| 120 | def __init__(self, local_id): |
| 121 | super(UpdateIssueAction, self).__init__() |
| 122 | self.local_id = local_id |
| 123 | |
| 124 | def Run(self, mc, services): |
| 125 | """Updates an issue based on the parsed commands.""" |
| 126 | try: |
| 127 | issue = services.issue.GetIssueByLocalID( |
| 128 | mc.cnxn, self.project.project_id, self.local_id, use_cache=False) |
| 129 | except exceptions.NoSuchIssueException: |
| 130 | return # Issue does not exist, so do nothing |
| 131 | |
| 132 | delta = tracker_pb2.IssueDelta() |
| 133 | |
| 134 | allow_edit = permissions.CanEditIssue( |
| 135 | mc.auth.effective_ids, mc.perms, self.project, issue) |
| 136 | |
| 137 | if allow_edit: |
| 138 | delta.summary = self.parser.summary or issue.summary |
| 139 | if self.parser.status is None: |
| 140 | delta.status = issue.status |
| 141 | else: |
| 142 | delta.status = self.parser.status |
| 143 | |
| 144 | if self.parser.owner_id is None: |
| 145 | delta.owner_id = issue.owner_id |
| 146 | else: |
| 147 | delta.owner_id = self.parser.owner_id |
| 148 | |
| 149 | delta.cc_ids_add = list(self.parser.cc_add) |
| 150 | delta.cc_ids_remove = list(self.parser.cc_remove) |
| 151 | delta.labels_add = self.parser.labels_add |
| 152 | delta.labels_remove = self.parser.labels_remove |
| 153 | # TODO(jrobbins): allow editing of custom fields |
| 154 | |
| 155 | with work_env.WorkEnv(mc, services) as we: |
| 156 | we.UpdateIssue( |
| 157 | issue, delta, self.description, inbound_message=self.inbound_message) |
| 158 | |
| 159 | logging.info('Updated issue %s:%s', |
| 160 | self.project.project_name, issue.local_id) |
| 161 | |
| 162 | # Note: notifications are generated in work_env. |