blob: 3ba376e6a1a12b457510c2c1237d492dc96a9675 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# 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."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import logging
12import re
13
14from framework import exceptions
15from framework import framework_bizobj
16from framework import framework_constants
17from tracker import tracker_constants
18
19
20def 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
61ASSIGN_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
68LABEL_COMMAND_RE = re.compile(
69 r'(?P<label>(?:\+|-)?\w(?:-|\w)*)',
70 re.UNICODE | re.IGNORECASE)
71
72
73def _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
106def _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
137class 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
252def _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
267def _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
282def _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
301def _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)