blob: 880d12649531559697fa8e8d261b42a69386ed3a [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""Classes and functions that implement command-line-like issue updates."""
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
10import logging
11import re
12
13from framework import exceptions
14from framework import framework_bizobj
15from framework import framework_constants
16from tracker import tracker_constants
17
18
19def ParseQuickEditCommand(
20 cnxn, cmd, issue, config, logged_in_user_id, services):
21 """Parse a quick edit command into assignments and labels."""
22 parts = _BreakCommandIntoParts(cmd)
23 parser = AssignmentParser(None, easier_kv_labels=True)
24
25 for key, value in parts:
26 if key: # A key=value assignment.
27 valid_assignment = parser.ParseAssignment(
28 cnxn, key, value, config, services, logged_in_user_id)
29 if not valid_assignment:
30 logging.info('ignoring assignment: %r, %r', key, value)
31
32 elif value.startswith('-'): # Removing a label.
33 parser.labels_remove.append(_StandardizeLabel(value[1:], config))
34
35 else: # Adding a label.
36 value = value.strip('+')
37 parser.labels_add.append(_StandardizeLabel(value, config))
38
39 new_summary = parser.summary or issue.summary
40
41 if parser.status is None:
42 new_status = issue.status
43 else:
44 new_status = parser.status
45
46 if parser.owner_id is None:
47 new_owner_id = issue.owner_id
48 else:
49 new_owner_id = parser.owner_id
50
51 new_cc_ids = [cc for cc in list(issue.cc_ids) + list(parser.cc_add)
52 if cc not in parser.cc_remove]
53 (new_labels, _update_add,
54 _update_remove) = framework_bizobj.MergeLabels(
55 issue.labels, parser.labels_add, parser.labels_remove, config)
56
57 return new_summary, new_status, new_owner_id, new_cc_ids, new_labels
58
59
60ASSIGN_COMMAND_RE = re.compile(
61 r'(?P<key>\w+(?:-|\w)*)(?:=|:)'
62 r'(?:(?P<value1>(?:-|\+|\.|%|@|=|,|\w)+)|'
63 r'"(?P<value2>[^"]+)"|'
64 r"'(?P<value3>[^']+)')",
65 re.UNICODE | re.IGNORECASE)
66
67LABEL_COMMAND_RE = re.compile(
68 r'(?P<label>(?:\+|-)?\w(?:-|\w)*)',
69 re.UNICODE | re.IGNORECASE)
70
71
72def _BreakCommandIntoParts(cmd):
73 """Break a quick edit command into assignment and label parts.
74
75 Args:
76 cmd: string command entered by the user.
77
78 Returns:
79 A list of (key, value) pairs where key is the name of the field
80 being assigned or None for OneWord labels, and value is the value
81 to assign to it, or the whole label. Value may begin with a "+"
82 which is just ignored, or a "-" meaning that the label should be
83 removed, or neither.
84 """
85 parts = []
86 cmd = cmd.strip()
87 m = True
88
89 while m:
90 m = ASSIGN_COMMAND_RE.match(cmd)
91 if m:
92 key = m.group('key')
93 value = m.group('value1') or m.group('value2') or m.group('value3')
94 parts.append((key, value))
95 cmd = cmd[len(m.group(0)):].strip()
96 else:
97 m = LABEL_COMMAND_RE.match(cmd)
98 if m:
99 parts.append((None, m.group('label')))
100 cmd = cmd[len(m.group(0)):].strip()
101
102 return parts
103
104
105def _ParsePlusMinusList(value):
106 """Parse a string containing a series of plus/minuse values.
107
108 Strings are seprated by whitespace, comma and/or semi-colon.
109
110 Example:
111 value = "one +two -three"
112 plus = ['one', 'two']
113 minus = ['three']
114
115 Args:
116 value: string containing unparsed plus minus values.
117
118 Returns:
119 A tuple of (plus, minus) string values.
120 """
121 plus = []
122 minus = []
123 # Treat ';' and ',' as separators (in addition to SPACE)
124 for ch in [',', ';']:
125 value = value.replace(ch, ' ')
126 terms = [i.strip() for i in value.split()]
127 for item in terms:
128 if item.startswith('-'):
129 minus.append(item.lstrip('-'))
130 else:
131 plus.append(item.lstrip('+')) # optional leading '+'
132
133 return plus, minus
134
135
136class AssignmentParser(object):
137 """Class to parse assignment statements in quick edits or email replies."""
138
139 def __init__(self, template, easier_kv_labels=False):
140 self.cc_list = []
141 self.cc_add = []
142 self.cc_remove = []
143 self.owner_id = None
144 self.status = None
145 self.summary = None
146 self.labels_list = []
147 self.labels_add = []
148 self.labels_remove = []
149 self.branch = None
150
151 # Accept "Anything=Anything" for quick-edit, but not in commit-log-commands
152 # because it would be too error-prone when mixed with plain text comment
153 # text and without autocomplete to help users triggering it via typos.
154 self.easier_kv_labels = easier_kv_labels
155
156 if template:
157 if template.owner_id:
158 self.owner_id = template.owner_id
159 if template.summary:
160 self.summary = template.summary
161 if template.labels:
162 self.labels_list = template.labels
163 # Do not have a similar check as above for status because it could be an
164 # empty string.
165 self.status = template.status
166
167 def ParseAssignment(self, cnxn, key, value, config, services, user_id):
168 """Parse command-style text entered by the user to update an issue.
169
170 E.g., The user may want to set the issue status to "reviewed", or
171 set the owner to "me".
172
173 Args:
174 cnxn: connection to SQL database.
175 key: string name of the field to set.
176 value: string value to be interpreted.
177 config: Projects' issue tracker configuration PB.
178 services: connections to backends.
179 user_id: int user ID of the user making the change.
180
181 Returns:
182 True if the line could be parsed as an assigment, False otherwise.
183 Also, as a side-effect, the assigned values are built up in the instance
184 variables of the parser.
185 """
186 valid_line = True
187
188 if key == 'owner':
189 if framework_constants.NO_VALUE_RE.match(value):
190 self.owner_id = framework_constants.NO_USER_SPECIFIED
191 else:
192 try:
193 self.owner_id = _LookupMeOrUsername(cnxn, value, services, user_id)
194 except exceptions.NoSuchUserException:
195 logging.warning('bad owner: %r when committing to project_id %r',
196 value, config.project_id)
197 valid_line = False
198
199 elif key == 'cc':
200 try:
201 add, remove = _ParsePlusMinusList(value)
202 self.cc_add = [_LookupMeOrUsername(cnxn, cc, services, user_id)
203 for cc in add if cc]
204 self.cc_remove = [_LookupMeOrUsername(cnxn, cc, services, user_id)
205 for cc in remove if cc]
206 for user_id in self.cc_add:
207 if user_id not in self.cc_list:
208 self.cc_list.append(user_id)
209 self.cc_list = [user_id for user_id in self.cc_list
210 if user_id not in self.cc_remove]
211 except exceptions.NoSuchUserException:
212 logging.warning('bad cc: %r when committing to project_id %r',
213 value, config.project_id)
214 valid_line = False
215
216 elif key == 'summary':
217 self.summary = value
218
219 elif key == 'status':
220 if framework_constants.NO_VALUE_RE.match(value):
221 self.status = ''
222 else:
223 self.status = _StandardizeStatus(value, config)
224
225 elif key == 'label' or key == 'labels':
226 self.labels_add, self.labels_remove = _ParsePlusMinusList(value)
227 self.labels_add = [_StandardizeLabel(lab, config)
228 for lab in self.labels_add]
229 self.labels_remove = [_StandardizeLabel(lab, config)
230 for lab in self.labels_remove]
231 (self.labels_list, _update_add,
232 _update_remove) = framework_bizobj.MergeLabels(
233 self.labels_list, self.labels_add, self.labels_remove, config)
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
251def _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
266def _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
281def _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
300def _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)