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 | """Classes and functions that implement command-line-like issue updates.""" |
| 7 | from __future__ import print_function |
| 8 | from __future__ import division |
| 9 | from __future__ import absolute_import |
| 10 | |
| 11 | import logging |
| 12 | import re |
| 13 | |
| 14 | from framework import exceptions |
| 15 | from framework import framework_bizobj |
| 16 | from framework import framework_constants |
| 17 | from tracker import tracker_constants |
| 18 | |
| 19 | |
| 20 | def ParseQuickEditCommand( |
| 21 | cnxn, cmd, issue, config, logged_in_user_id, services): |
| 22 | """Parse a quick edit command into assignments and labels.""" |
| 23 | parts = _BreakCommandIntoParts(cmd) |
| 24 | parser = AssignmentParser(None, easier_kv_labels=True) |
| 25 | |
| 26 | for key, value in parts: |
| 27 | if key: # A key=value assignment. |
| 28 | valid_assignment = parser.ParseAssignment( |
| 29 | cnxn, key, value, config, services, logged_in_user_id) |
| 30 | if not valid_assignment: |
| 31 | logging.info('ignoring assignment: %r, %r', key, value) |
| 32 | |
| 33 | elif value.startswith('-'): # Removing a label. |
| 34 | parser.labels_remove.append(_StandardizeLabel(value[1:], config)) |
| 35 | |
| 36 | else: # Adding a label. |
| 37 | value = value.strip('+') |
| 38 | parser.labels_add.append(_StandardizeLabel(value, config)) |
| 39 | |
| 40 | new_summary = parser.summary or issue.summary |
| 41 | |
| 42 | if parser.status is None: |
| 43 | new_status = issue.status |
| 44 | else: |
| 45 | new_status = parser.status |
| 46 | |
| 47 | if parser.owner_id is None: |
| 48 | new_owner_id = issue.owner_id |
| 49 | else: |
| 50 | new_owner_id = parser.owner_id |
| 51 | |
| 52 | new_cc_ids = [cc for cc in list(issue.cc_ids) + list(parser.cc_add) |
| 53 | if cc not in parser.cc_remove] |
| 54 | (new_labels, _update_add, |
| 55 | _update_remove) = framework_bizobj.MergeLabels( |
| 56 | issue.labels, parser.labels_add, parser.labels_remove, config) |
| 57 | |
| 58 | return new_summary, new_status, new_owner_id, new_cc_ids, new_labels |
| 59 | |
| 60 | |
| 61 | ASSIGN_COMMAND_RE = re.compile( |
| 62 | r'(?P<key>\w+(?:-|\w)*)(?:=|:)' |
| 63 | r'(?:(?P<value1>(?:-|\+|\.|%|@|=|,|\w)+)|' |
| 64 | r'"(?P<value2>[^"]+)"|' |
| 65 | r"'(?P<value3>[^']+)')", |
| 66 | re.UNICODE | re.IGNORECASE) |
| 67 | |
| 68 | LABEL_COMMAND_RE = re.compile( |
| 69 | r'(?P<label>(?:\+|-)?\w(?:-|\w)*)', |
| 70 | re.UNICODE | re.IGNORECASE) |
| 71 | |
| 72 | |
| 73 | def _BreakCommandIntoParts(cmd): |
| 74 | """Break a quick edit command into assignment and label parts. |
| 75 | |
| 76 | Args: |
| 77 | cmd: string command entered by the user. |
| 78 | |
| 79 | Returns: |
| 80 | A list of (key, value) pairs where key is the name of the field |
| 81 | being assigned or None for OneWord labels, and value is the value |
| 82 | to assign to it, or the whole label. Value may begin with a "+" |
| 83 | which is just ignored, or a "-" meaning that the label should be |
| 84 | removed, or neither. |
| 85 | """ |
| 86 | parts = [] |
| 87 | cmd = cmd.strip() |
| 88 | m = True |
| 89 | |
| 90 | while m: |
| 91 | m = ASSIGN_COMMAND_RE.match(cmd) |
| 92 | if m: |
| 93 | key = m.group('key') |
| 94 | value = m.group('value1') or m.group('value2') or m.group('value3') |
| 95 | parts.append((key, value)) |
| 96 | cmd = cmd[len(m.group(0)):].strip() |
| 97 | else: |
| 98 | m = LABEL_COMMAND_RE.match(cmd) |
| 99 | if m: |
| 100 | parts.append((None, m.group('label'))) |
| 101 | cmd = cmd[len(m.group(0)):].strip() |
| 102 | |
| 103 | return parts |
| 104 | |
| 105 | |
| 106 | def _ParsePlusMinusList(value): |
| 107 | """Parse a string containing a series of plus/minuse values. |
| 108 | |
| 109 | Strings are seprated by whitespace, comma and/or semi-colon. |
| 110 | |
| 111 | Example: |
| 112 | value = "one +two -three" |
| 113 | plus = ['one', 'two'] |
| 114 | minus = ['three'] |
| 115 | |
| 116 | Args: |
| 117 | value: string containing unparsed plus minus values. |
| 118 | |
| 119 | Returns: |
| 120 | A tuple of (plus, minus) string values. |
| 121 | """ |
| 122 | plus = [] |
| 123 | minus = [] |
| 124 | # Treat ';' and ',' as separators (in addition to SPACE) |
| 125 | for ch in [',', ';']: |
| 126 | value = value.replace(ch, ' ') |
| 127 | terms = [i.strip() for i in value.split()] |
| 128 | for item in terms: |
| 129 | if item.startswith('-'): |
| 130 | minus.append(item.lstrip('-')) |
| 131 | else: |
| 132 | plus.append(item.lstrip('+')) # optional leading '+' |
| 133 | |
| 134 | return plus, minus |
| 135 | |
| 136 | |
| 137 | class AssignmentParser(object): |
| 138 | """Class to parse assignment statements in quick edits or email replies.""" |
| 139 | |
| 140 | def __init__(self, template, easier_kv_labels=False): |
| 141 | self.cc_list = [] |
| 142 | self.cc_add = [] |
| 143 | self.cc_remove = [] |
| 144 | self.owner_id = None |
| 145 | self.status = None |
| 146 | self.summary = None |
| 147 | self.labels_list = [] |
| 148 | self.labels_add = [] |
| 149 | self.labels_remove = [] |
| 150 | self.branch = None |
| 151 | |
| 152 | # Accept "Anything=Anything" for quick-edit, but not in commit-log-commands |
| 153 | # because it would be too error-prone when mixed with plain text comment |
| 154 | # text and without autocomplete to help users triggering it via typos. |
| 155 | self.easier_kv_labels = easier_kv_labels |
| 156 | |
| 157 | if template: |
| 158 | if template.owner_id: |
| 159 | self.owner_id = template.owner_id |
| 160 | if template.summary: |
| 161 | self.summary = template.summary |
| 162 | if template.labels: |
| 163 | self.labels_list = template.labels |
| 164 | # Do not have a similar check as above for status because it could be an |
| 165 | # empty string. |
| 166 | self.status = template.status |
| 167 | |
| 168 | def ParseAssignment(self, cnxn, key, value, config, services, user_id): |
| 169 | """Parse command-style text entered by the user to update an issue. |
| 170 | |
| 171 | E.g., The user may want to set the issue status to "reviewed", or |
| 172 | set the owner to "me". |
| 173 | |
| 174 | Args: |
| 175 | cnxn: connection to SQL database. |
| 176 | key: string name of the field to set. |
| 177 | value: string value to be interpreted. |
| 178 | config: Projects' issue tracker configuration PB. |
| 179 | services: connections to backends. |
| 180 | user_id: int user ID of the user making the change. |
| 181 | |
| 182 | Returns: |
| 183 | True if the line could be parsed as an assigment, False otherwise. |
| 184 | Also, as a side-effect, the assigned values are built up in the instance |
| 185 | variables of the parser. |
| 186 | """ |
| 187 | valid_line = True |
| 188 | |
| 189 | if key == 'owner': |
| 190 | if framework_constants.NO_VALUE_RE.match(value): |
| 191 | self.owner_id = framework_constants.NO_USER_SPECIFIED |
| 192 | else: |
| 193 | try: |
| 194 | self.owner_id = _LookupMeOrUsername(cnxn, value, services, user_id) |
| 195 | except exceptions.NoSuchUserException: |
| 196 | logging.warning('bad owner: %r when committing to project_id %r', |
| 197 | value, config.project_id) |
| 198 | valid_line = False |
| 199 | |
| 200 | elif key == 'cc': |
| 201 | try: |
| 202 | add, remove = _ParsePlusMinusList(value) |
| 203 | self.cc_add = [_LookupMeOrUsername(cnxn, cc, services, user_id) |
| 204 | for cc in add if cc] |
| 205 | self.cc_remove = [_LookupMeOrUsername(cnxn, cc, services, user_id) |
| 206 | for cc in remove if cc] |
| 207 | for user_id in self.cc_add: |
| 208 | if user_id not in self.cc_list: |
| 209 | self.cc_list.append(user_id) |
| 210 | self.cc_list = [user_id for user_id in self.cc_list |
| 211 | if user_id not in self.cc_remove] |
| 212 | except exceptions.NoSuchUserException: |
| 213 | logging.warning('bad cc: %r when committing to project_id %r', |
| 214 | value, config.project_id) |
| 215 | valid_line = False |
| 216 | |
| 217 | elif key == 'summary': |
| 218 | self.summary = value |
| 219 | |
| 220 | elif key == 'status': |
| 221 | if framework_constants.NO_VALUE_RE.match(value): |
| 222 | self.status = '' |
| 223 | else: |
| 224 | self.status = _StandardizeStatus(value, config) |
| 225 | |
| 226 | elif key == 'label' or key == 'labels': |
| 227 | self.labels_add, self.labels_remove = _ParsePlusMinusList(value) |
| 228 | self.labels_add = [_StandardizeLabel(lab, config) |
| 229 | for lab in self.labels_add] |
| 230 | self.labels_remove = [_StandardizeLabel(lab, config) |
| 231 | for lab in self.labels_remove] |
| 232 | (self.labels_list, _update_add, |
| 233 | _update_remove) = framework_bizobj.MergeLabels( |
| 234 | self.labels_list, self.labels_add, self.labels_remove, config) |
| 235 | |
| 236 | elif (self.easier_kv_labels and |
| 237 | key not in tracker_constants.RESERVED_PREFIXES and |
| 238 | key and value): |
| 239 | if key.startswith('-'): |
| 240 | self.labels_remove.append(_StandardizeLabel( |
| 241 | '%s-%s' % (key[1:], value), config)) |
| 242 | else: |
| 243 | self.labels_add.append(_StandardizeLabel( |
| 244 | '%s-%s' % (key, value), config)) |
| 245 | |
| 246 | else: |
| 247 | valid_line = False |
| 248 | |
| 249 | return valid_line |
| 250 | |
| 251 | |
| 252 | def _StandardizeStatus(status, config): |
| 253 | """Attempt to match a user-supplied status with standard status values. |
| 254 | |
| 255 | Args: |
| 256 | status: User-supplied status string. |
| 257 | config: Project's issue tracker configuration PB. |
| 258 | |
| 259 | Returns: |
| 260 | A canonicalized status string, that matches a standard project |
| 261 | value, if found. |
| 262 | """ |
| 263 | well_known_statuses = [wks.status for wks in config.well_known_statuses] |
| 264 | return _StandardizeArtifact(status, well_known_statuses) |
| 265 | |
| 266 | |
| 267 | def _StandardizeLabel(label, config): |
| 268 | """Attempt to match a user-supplied label with standard label values. |
| 269 | |
| 270 | Args: |
| 271 | label: User-supplied label string. |
| 272 | config: Project's issue tracker configuration PB. |
| 273 | |
| 274 | Returns: |
| 275 | A canonicalized label string, that matches a standard project |
| 276 | value, if found. |
| 277 | """ |
| 278 | well_known_labels = [wkl.label for wkl in config.well_known_labels] |
| 279 | return _StandardizeArtifact(label, well_known_labels) |
| 280 | |
| 281 | |
| 282 | def _StandardizeArtifact(artifact, well_known_artifacts): |
| 283 | """Attempt to match a user-supplied artifact with standard artifact values. |
| 284 | |
| 285 | Args: |
| 286 | artifact: User-supplied artifact string. |
| 287 | well_known_artifacts: List of well known values of the artifact. |
| 288 | |
| 289 | Returns: |
| 290 | A canonicalized artifact string, that matches a standard project |
| 291 | value, if found. |
| 292 | """ |
| 293 | artifact = framework_bizobj.CanonicalizeLabel(artifact) |
| 294 | for wka in well_known_artifacts: |
| 295 | if artifact.lower() == wka.lower(): |
| 296 | return wka |
| 297 | # No match - use user-supplied artifact. |
| 298 | return artifact |
| 299 | |
| 300 | |
| 301 | def _LookupMeOrUsername(cnxn, username, services, user_id): |
| 302 | """Handle the 'me' syntax or lookup a user's user ID.""" |
| 303 | if username.lower() == 'me': |
| 304 | return user_id |
| 305 | |
| 306 | return services.user.LookupUserID(cnxn, username) |