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