|
1 # Copyright (c) 2009 Google Inc. All rights reserved. |
|
2 # Copyright (c) 2009 Apple Inc. All rights reserved. |
|
3 # Copyright (c) 2010 Research In Motion Limited. All rights reserved. |
|
4 # |
|
5 # Redistribution and use in source and binary forms, with or without |
|
6 # modification, are permitted provided that the following conditions are |
|
7 # met: |
|
8 # |
|
9 # * Redistributions of source code must retain the above copyright |
|
10 # notice, this list of conditions and the following disclaimer. |
|
11 # * Redistributions in binary form must reproduce the above |
|
12 # copyright notice, this list of conditions and the following disclaimer |
|
13 # in the documentation and/or other materials provided with the |
|
14 # distribution. |
|
15 # * Neither the name of Google Inc. nor the names of its |
|
16 # contributors may be used to endorse or promote products derived from |
|
17 # this software without specific prior written permission. |
|
18 # |
|
19 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
|
20 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
|
21 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
|
22 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
|
23 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
|
24 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
|
25 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
|
26 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
|
27 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
|
28 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
|
29 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|
30 # |
|
31 # WebKit's Python module for interacting with Bugzilla |
|
32 |
|
33 import os.path |
|
34 import re |
|
35 import StringIO |
|
36 |
|
37 from datetime import datetime # used in timestamp() |
|
38 |
|
39 from webkitpy.common.system.deprecated_logging import error, log |
|
40 from webkitpy.common.config import committers |
|
41 from webkitpy.common.net.credentials import Credentials |
|
42 from webkitpy.common.system.ospath import relpath |
|
43 from webkitpy.common.system.user import User |
|
44 from webkitpy.thirdparty.autoinstalled.mechanize import Browser |
|
45 from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, SoupStrainer |
|
46 |
|
47 |
|
48 def parse_bug_id(message): |
|
49 if not message: |
|
50 return None |
|
51 match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message) |
|
52 if match: |
|
53 return int(match.group('bug_id')) |
|
54 match = re.search( |
|
55 Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", |
|
56 message) |
|
57 if match: |
|
58 return int(match.group('bug_id')) |
|
59 return None |
|
60 |
|
61 |
|
62 def timestamp(): |
|
63 return datetime.now().strftime("%Y%m%d%H%M%S") |
|
64 |
|
65 |
|
66 class Attachment(object): |
|
67 |
|
68 rollout_preamble = "ROLLOUT of r" |
|
69 |
|
70 def __init__(self, attachment_dictionary, bug): |
|
71 self._attachment_dictionary = attachment_dictionary |
|
72 self._bug = bug |
|
73 self._reviewer = None |
|
74 self._committer = None |
|
75 |
|
76 def _bugzilla(self): |
|
77 return self._bug._bugzilla |
|
78 |
|
79 def id(self): |
|
80 return int(self._attachment_dictionary.get("id")) |
|
81 |
|
82 def attacher_is_committer(self): |
|
83 return self._bugzilla.committers.committer_by_email( |
|
84 patch.attacher_email()) |
|
85 |
|
86 def attacher_email(self): |
|
87 return self._attachment_dictionary.get("attacher_email") |
|
88 |
|
89 def bug(self): |
|
90 return self._bug |
|
91 |
|
92 def bug_id(self): |
|
93 return int(self._attachment_dictionary.get("bug_id")) |
|
94 |
|
95 def is_patch(self): |
|
96 return not not self._attachment_dictionary.get("is_patch") |
|
97 |
|
98 def is_obsolete(self): |
|
99 return not not self._attachment_dictionary.get("is_obsolete") |
|
100 |
|
101 def is_rollout(self): |
|
102 return self.name().startswith(self.rollout_preamble) |
|
103 |
|
104 def name(self): |
|
105 return self._attachment_dictionary.get("name") |
|
106 |
|
107 def attach_date(self): |
|
108 return self._attachment_dictionary.get("attach_date") |
|
109 |
|
110 def review(self): |
|
111 return self._attachment_dictionary.get("review") |
|
112 |
|
113 def commit_queue(self): |
|
114 return self._attachment_dictionary.get("commit-queue") |
|
115 |
|
116 def in_rietveld(self): |
|
117 return self._attachment_dictionary.get("in-rietveld") |
|
118 |
|
119 def url(self): |
|
120 # FIXME: This should just return |
|
121 # self._bugzilla().attachment_url_for_id(self.id()). scm_unittest.py |
|
122 # depends on the current behavior. |
|
123 return self._attachment_dictionary.get("url") |
|
124 |
|
125 def contents(self): |
|
126 # FIXME: We shouldn't be grabbing at _bugzilla. |
|
127 return self._bug._bugzilla.fetch_attachment_contents(self.id()) |
|
128 |
|
129 def _validate_flag_value(self, flag): |
|
130 email = self._attachment_dictionary.get("%s_email" % flag) |
|
131 if not email: |
|
132 return None |
|
133 committer = getattr(self._bugzilla().committers, |
|
134 "%s_by_email" % flag)(email) |
|
135 if committer: |
|
136 return committer |
|
137 log("Warning, attachment %s on bug %s has invalid %s (%s)" % ( |
|
138 self._attachment_dictionary['id'], |
|
139 self._attachment_dictionary['bug_id'], flag, email)) |
|
140 |
|
141 def reviewer(self): |
|
142 if not self._reviewer: |
|
143 self._reviewer = self._validate_flag_value("reviewer") |
|
144 return self._reviewer |
|
145 |
|
146 def committer(self): |
|
147 if not self._committer: |
|
148 self._committer = self._validate_flag_value("committer") |
|
149 return self._committer |
|
150 |
|
151 |
|
152 class Bug(object): |
|
153 # FIXME: This class is kinda a hack for now. It exists so we have one |
|
154 # place to hold bug logic, even if much of the code deals with |
|
155 # dictionaries still. |
|
156 |
|
157 def __init__(self, bug_dictionary, bugzilla): |
|
158 self.bug_dictionary = bug_dictionary |
|
159 self._bugzilla = bugzilla |
|
160 |
|
161 def id(self): |
|
162 return self.bug_dictionary["id"] |
|
163 |
|
164 def title(self): |
|
165 return self.bug_dictionary["title"] |
|
166 |
|
167 def assigned_to_email(self): |
|
168 return self.bug_dictionary["assigned_to_email"] |
|
169 |
|
170 # FIXME: This information should be stored in some sort of webkit_config.py instead of here. |
|
171 unassigned_emails = frozenset([ |
|
172 "webkit-unassigned@lists.webkit.org", |
|
173 "webkit-qt-unassigned@trolltech.com", |
|
174 ]) |
|
175 def is_unassigned(self): |
|
176 return self.assigned_to_email() in self.unassigned_emails |
|
177 |
|
178 # Rarely do we actually want obsolete attachments |
|
179 def attachments(self, include_obsolete=False): |
|
180 attachments = self.bug_dictionary["attachments"] |
|
181 if not include_obsolete: |
|
182 attachments = filter(lambda attachment: |
|
183 not attachment["is_obsolete"], attachments) |
|
184 return [Attachment(attachment, self) for attachment in attachments] |
|
185 |
|
186 def patches(self, include_obsolete=False): |
|
187 return [patch for patch in self.attachments(include_obsolete) |
|
188 if patch.is_patch()] |
|
189 |
|
190 def unreviewed_patches(self): |
|
191 return [patch for patch in self.patches() if patch.review() == "?"] |
|
192 |
|
193 def reviewed_patches(self, include_invalid=False): |
|
194 patches = [patch for patch in self.patches() if patch.review() == "+"] |
|
195 if include_invalid: |
|
196 return patches |
|
197 # Checking reviewer() ensures that it was both reviewed and has a valid |
|
198 # reviewer. |
|
199 return filter(lambda patch: patch.reviewer(), patches) |
|
200 |
|
201 def commit_queued_patches(self, include_invalid=False): |
|
202 patches = [patch for patch in self.patches() |
|
203 if patch.commit_queue() == "+"] |
|
204 if include_invalid: |
|
205 return patches |
|
206 # Checking committer() ensures that it was both commit-queue+'d and has |
|
207 # a valid committer. |
|
208 return filter(lambda patch: patch.committer(), patches) |
|
209 |
|
210 def in_rietveld_queue_patches(self): |
|
211 return [patch for patch in self.patches() if patch.in_rietveld() == None] |
|
212 |
|
213 |
|
214 # A container for all of the logic for making and parsing buzilla queries. |
|
215 class BugzillaQueries(object): |
|
216 |
|
217 def __init__(self, bugzilla): |
|
218 self._bugzilla = bugzilla |
|
219 |
|
220 # Note: _load_query and _fetch_bug are the only two methods which access |
|
221 # self._bugzilla. |
|
222 |
|
223 def _load_query(self, query): |
|
224 self._bugzilla.authenticate() |
|
225 |
|
226 full_url = "%s%s" % (self._bugzilla.bug_server_url, query) |
|
227 return self._bugzilla.browser.open(full_url) |
|
228 |
|
229 def _fetch_bug(self, bug_id): |
|
230 return self._bugzilla.fetch_bug(bug_id) |
|
231 |
|
232 def _fetch_bug_ids_advanced_query(self, query): |
|
233 soup = BeautifulSoup(self._load_query(query)) |
|
234 # The contents of the <a> inside the cells in the first column happen |
|
235 # to be the bug id. |
|
236 return [int(bug_link_cell.find("a").string) |
|
237 for bug_link_cell in soup('td', "first-child")] |
|
238 |
|
239 def _parse_attachment_ids_request_query(self, page): |
|
240 digits = re.compile("\d+") |
|
241 attachment_href = re.compile("attachment.cgi\?id=\d+&action=review") |
|
242 attachment_links = SoupStrainer("a", href=attachment_href) |
|
243 return [int(digits.search(tag["href"]).group(0)) |
|
244 for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)] |
|
245 |
|
246 def _fetch_attachment_ids_request_query(self, query): |
|
247 return self._parse_attachment_ids_request_query(self._load_query(query)) |
|
248 |
|
249 def _parse_quips(self, page): |
|
250 soup = BeautifulSoup(page, convertEntities=BeautifulSoup.HTML_ENTITIES) |
|
251 quips = soup.find(text=re.compile(r"Existing quips:")).findNext("ul").findAll("li") |
|
252 return [unicode(quip_entry.string) for quip_entry in quips] |
|
253 |
|
254 def fetch_quips(self): |
|
255 return self._parse_quips(self._load_query("/quips.cgi?action=show")) |
|
256 |
|
257 # List of all r+'d bugs. |
|
258 def fetch_bug_ids_from_pending_commit_list(self): |
|
259 needs_commit_query_url = "buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=equals&value0-0-0=review%2B" |
|
260 return self._fetch_bug_ids_advanced_query(needs_commit_query_url) |
|
261 |
|
262 def fetch_patches_from_pending_commit_list(self): |
|
263 return sum([self._fetch_bug(bug_id).reviewed_patches() |
|
264 for bug_id in self.fetch_bug_ids_from_pending_commit_list()], []) |
|
265 |
|
266 def fetch_bug_ids_from_commit_queue(self): |
|
267 commit_queue_url = "buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=equals&value0-0-0=commit-queue%2B&order=Last+Changed" |
|
268 return self._fetch_bug_ids_advanced_query(commit_queue_url) |
|
269 |
|
270 def fetch_patches_from_commit_queue(self): |
|
271 # This function will only return patches which have valid committers |
|
272 # set. It won't reject patches with invalid committers/reviewers. |
|
273 return sum([self._fetch_bug(bug_id).commit_queued_patches() |
|
274 for bug_id in self.fetch_bug_ids_from_commit_queue()], []) |
|
275 |
|
276 def fetch_first_patch_from_rietveld_queue(self): |
|
277 # rietveld-queue processes all patches that don't have in-rietveld set. |
|
278 query_url = "buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=notsubstring&value0-0-0=in-rietveld&field0-1-0=attachments.ispatch&type0-1-0=equals&value0-1-0=1&order=Last+Changed&field0-2-0=attachments.isobsolete&type0-2-0=equals&value0-2-0=0" |
|
279 bugs = self._fetch_bug_ids_advanced_query(query_url) |
|
280 if not len(bugs): |
|
281 return None |
|
282 |
|
283 patches = self._fetch_bug(bugs[0]).in_rietveld_queue_patches() |
|
284 return patches[0] if len(patches) else None |
|
285 |
|
286 def _fetch_bug_ids_from_review_queue(self): |
|
287 review_queue_url = "buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=equals&value0-0-0=review?" |
|
288 return self._fetch_bug_ids_advanced_query(review_queue_url) |
|
289 |
|
290 def fetch_patches_from_review_queue(self, limit=None): |
|
291 # [:None] returns the whole array. |
|
292 return sum([self._fetch_bug(bug_id).unreviewed_patches() |
|
293 for bug_id in self._fetch_bug_ids_from_review_queue()[:limit]], []) |
|
294 |
|
295 # FIXME: Why do we have both fetch_patches_from_review_queue and |
|
296 # fetch_attachment_ids_from_review_queue?? |
|
297 # NOTE: This is also the only client of _fetch_attachment_ids_request_query |
|
298 |
|
299 def fetch_attachment_ids_from_review_queue(self): |
|
300 review_queue_url = "request.cgi?action=queue&type=review&group=type" |
|
301 return self._fetch_attachment_ids_request_query(review_queue_url) |
|
302 |
|
303 |
|
304 class CommitterValidator(object): |
|
305 |
|
306 def __init__(self, bugzilla): |
|
307 self._bugzilla = bugzilla |
|
308 |
|
309 # _view_source_url belongs in some sort of webkit_config.py module. |
|
310 def _view_source_url(self, local_path): |
|
311 return "http://trac.webkit.org/browser/trunk/%s" % local_path |
|
312 |
|
313 def _checkout_root(self): |
|
314 # FIXME: This is a hack, we would have this from scm.checkout_root |
|
315 # if we had any way to get to an scm object here. |
|
316 components = __file__.split(os.sep) |
|
317 tools_index = components.index("WebKitTools") |
|
318 return os.sep.join(components[:tools_index]) |
|
319 |
|
320 def _committers_py_path(self): |
|
321 # extension can sometimes be .pyc, we always want .py |
|
322 (path, extension) = os.path.splitext(committers.__file__) |
|
323 # FIXME: When we're allowed to use python 2.6 we can use the real |
|
324 # os.path.relpath |
|
325 path = relpath(path, self._checkout_root()) |
|
326 return ".".join([path, "py"]) |
|
327 |
|
328 def _flag_permission_rejection_message(self, setter_email, flag_name): |
|
329 # Should come from some webkit_config.py |
|
330 contribution_guidlines = "http://webkit.org/coding/contributing.html" |
|
331 # This could be queried from the status_server. |
|
332 queue_administrator = "eseidel@chromium.org" |
|
333 # This could be queried from the tool. |
|
334 queue_name = "commit-queue" |
|
335 committers_list = self._committers_py_path() |
|
336 message = "%s does not have %s permissions according to %s." % ( |
|
337 setter_email, |
|
338 flag_name, |
|
339 self._view_source_url(committers_list)) |
|
340 message += "\n\n- If you do not have %s rights please read %s for instructions on how to use bugzilla flags." % ( |
|
341 flag_name, contribution_guidlines) |
|
342 message += "\n\n- If you have %s rights please correct the error in %s by adding yourself to the file (no review needed). " % ( |
|
343 flag_name, committers_list) |
|
344 message += "Due to bug 30084 the %s will require a restart after your change. " % queue_name |
|
345 message += "Please contact %s to request a %s restart. " % ( |
|
346 queue_administrator, queue_name) |
|
347 message += "After restart the %s will correctly respect your %s rights." % ( |
|
348 queue_name, flag_name) |
|
349 return message |
|
350 |
|
351 def _validate_setter_email(self, patch, result_key, rejection_function): |
|
352 committer = getattr(patch, result_key)() |
|
353 # If the flag is set, and we don't recognize the setter, reject the |
|
354 # flag! |
|
355 setter_email = patch._attachment_dictionary.get("%s_email" % result_key) |
|
356 if setter_email and not committer: |
|
357 rejection_function(patch.id(), |
|
358 self._flag_permission_rejection_message(setter_email, |
|
359 result_key)) |
|
360 return False |
|
361 return True |
|
362 |
|
363 def patches_after_rejecting_invalid_commiters_and_reviewers(self, patches): |
|
364 validated_patches = [] |
|
365 for patch in patches: |
|
366 if (self._validate_setter_email( |
|
367 patch, "reviewer", self.reject_patch_from_review_queue) |
|
368 and self._validate_setter_email( |
|
369 patch, "committer", self.reject_patch_from_commit_queue)): |
|
370 validated_patches.append(patch) |
|
371 return validated_patches |
|
372 |
|
373 def reject_patch_from_commit_queue(self, |
|
374 attachment_id, |
|
375 additional_comment_text=None): |
|
376 comment_text = "Rejecting patch %s from commit-queue." % attachment_id |
|
377 self._bugzilla.set_flag_on_attachment(attachment_id, |
|
378 "commit-queue", |
|
379 "-", |
|
380 comment_text, |
|
381 additional_comment_text) |
|
382 |
|
383 def reject_patch_from_review_queue(self, |
|
384 attachment_id, |
|
385 additional_comment_text=None): |
|
386 comment_text = "Rejecting patch %s from review queue." % attachment_id |
|
387 self._bugzilla.set_flag_on_attachment(attachment_id, |
|
388 'review', |
|
389 '-', |
|
390 comment_text, |
|
391 additional_comment_text) |
|
392 |
|
393 |
|
394 class Bugzilla(object): |
|
395 |
|
396 def __init__(self, dryrun=False, committers=committers.CommitterList()): |
|
397 self.dryrun = dryrun |
|
398 self.authenticated = False |
|
399 self.queries = BugzillaQueries(self) |
|
400 self.committers = committers |
|
401 self.cached_quips = [] |
|
402 |
|
403 # FIXME: We should use some sort of Browser mock object when in dryrun |
|
404 # mode (to prevent any mistakes). |
|
405 self.browser = Browser() |
|
406 # Ignore bugs.webkit.org/robots.txt until we fix it to allow this |
|
407 # script. |
|
408 self.browser.set_handle_robots(False) |
|
409 |
|
410 # FIXME: Much of this should go into some sort of config module: |
|
411 bug_server_host = "bugs.webkit.org" |
|
412 bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host) |
|
413 bug_server_url = "https://%s/" % bug_server_host |
|
414 |
|
415 def quips(self): |
|
416 # We only fetch and parse the list of quips once per instantiation |
|
417 # so that we do not burden bugs.webkit.org. |
|
418 if not self.cached_quips and not self.dryrun: |
|
419 self.cached_quips = self.queries.fetch_quips() |
|
420 return self.cached_quips |
|
421 |
|
422 def bug_url_for_bug_id(self, bug_id, xml=False): |
|
423 if not bug_id: |
|
424 return None |
|
425 content_type = "&ctype=xml" if xml else "" |
|
426 return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url, |
|
427 bug_id, |
|
428 content_type) |
|
429 |
|
430 def short_bug_url_for_bug_id(self, bug_id): |
|
431 if not bug_id: |
|
432 return None |
|
433 return "http://webkit.org/b/%s" % bug_id |
|
434 |
|
435 def attachment_url_for_id(self, attachment_id, action="view"): |
|
436 if not attachment_id: |
|
437 return None |
|
438 action_param = "" |
|
439 if action and action != "view": |
|
440 action_param = "&action=%s" % action |
|
441 return "%sattachment.cgi?id=%s%s" % (self.bug_server_url, |
|
442 attachment_id, |
|
443 action_param) |
|
444 |
|
445 def _parse_attachment_flag(self, |
|
446 element, |
|
447 flag_name, |
|
448 attachment, |
|
449 result_key): |
|
450 flag = element.find('flag', attrs={'name': flag_name}) |
|
451 if flag: |
|
452 attachment[flag_name] = flag['status'] |
|
453 if flag['status'] == '+': |
|
454 attachment[result_key] = flag['setter'] |
|
455 # Sadly show_bug.cgi?ctype=xml does not expose the flag modification date. |
|
456 |
|
457 def _string_contents(self, soup): |
|
458 # WebKit's bugzilla instance uses UTF-8. |
|
459 # BeautifulSoup always returns Unicode strings, however |
|
460 # the .string method returns a (unicode) NavigableString. |
|
461 # NavigableString can confuse other parts of the code, so we |
|
462 # convert from NavigableString to a real unicode() object using unicode(). |
|
463 return unicode(soup.string) |
|
464 |
|
465 # Example: 2010-01-20 14:31 PST |
|
466 # FIXME: Some bugzilla dates seem to have seconds in them? |
|
467 # Python does not support timezones out of the box. |
|
468 # Assume that bugzilla always uses PST (which is true for bugs.webkit.org) |
|
469 _bugzilla_date_format = "%Y-%m-%d %H:%M" |
|
470 |
|
471 @classmethod |
|
472 def _parse_date(cls, date_string): |
|
473 (date, time, time_zone) = date_string.split(" ") |
|
474 # Ignore the timezone because python doesn't understand timezones out of the box. |
|
475 date_string = "%s %s" % (date, time) |
|
476 return datetime.strptime(date_string, cls._bugzilla_date_format) |
|
477 |
|
478 def _date_contents(self, soup): |
|
479 return self._parse_date(self._string_contents(soup)) |
|
480 |
|
481 def _parse_attachment_element(self, element, bug_id): |
|
482 attachment = {} |
|
483 attachment['bug_id'] = bug_id |
|
484 attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1") |
|
485 attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1") |
|
486 attachment['id'] = int(element.find('attachid').string) |
|
487 # FIXME: No need to parse out the url here. |
|
488 attachment['url'] = self.attachment_url_for_id(attachment['id']) |
|
489 attachment["attach_date"] = self._date_contents(element.find("date")) |
|
490 attachment['name'] = self._string_contents(element.find('desc')) |
|
491 attachment['attacher_email'] = self._string_contents(element.find('attacher')) |
|
492 attachment['type'] = self._string_contents(element.find('type')) |
|
493 self._parse_attachment_flag( |
|
494 element, 'review', attachment, 'reviewer_email') |
|
495 self._parse_attachment_flag( |
|
496 element, 'in-rietveld', attachment, 'rietveld_uploader_email') |
|
497 self._parse_attachment_flag( |
|
498 element, 'commit-queue', attachment, 'committer_email') |
|
499 return attachment |
|
500 |
|
501 def _parse_bug_page(self, page): |
|
502 soup = BeautifulSoup(page) |
|
503 bug = {} |
|
504 bug["id"] = int(soup.find("bug_id").string) |
|
505 bug["title"] = self._string_contents(soup.find("short_desc")) |
|
506 bug["reporter_email"] = self._string_contents(soup.find("reporter")) |
|
507 bug["assigned_to_email"] = self._string_contents(soup.find("assigned_to")) |
|
508 bug["cc_emails"] = [self._string_contents(element) |
|
509 for element in soup.findAll('cc')] |
|
510 bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')] |
|
511 return bug |
|
512 |
|
513 # Makes testing fetch_*_from_bug() possible until we have a better |
|
514 # BugzillaNetwork abstration. |
|
515 |
|
516 def _fetch_bug_page(self, bug_id): |
|
517 bug_url = self.bug_url_for_bug_id(bug_id, xml=True) |
|
518 log("Fetching: %s" % bug_url) |
|
519 return self.browser.open(bug_url) |
|
520 |
|
521 def fetch_bug_dictionary(self, bug_id): |
|
522 try: |
|
523 return self._parse_bug_page(self._fetch_bug_page(bug_id)) |
|
524 except: |
|
525 self.authenticate() |
|
526 return self._parse_bug_page(self._fetch_bug_page(bug_id)) |
|
527 |
|
528 # FIXME: A BugzillaCache object should provide all these fetch_ methods. |
|
529 |
|
530 def fetch_bug(self, bug_id): |
|
531 return Bug(self.fetch_bug_dictionary(bug_id), self) |
|
532 |
|
533 def fetch_attachment_contents(self, attachment_id): |
|
534 attachment_url = self.attachment_url_for_id(attachment_id) |
|
535 # We need to authenticate to download patches from security bugs. |
|
536 self.authenticate() |
|
537 return self.browser.open(attachment_url).read() |
|
538 |
|
539 def _parse_bug_id_from_attachment_page(self, page): |
|
540 # The "Up" relation happens to point to the bug. |
|
541 up_link = BeautifulSoup(page).find('link', rel='Up') |
|
542 if not up_link: |
|
543 # This attachment does not exist (or you don't have permissions to |
|
544 # view it). |
|
545 return None |
|
546 match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href']) |
|
547 return int(match.group('bug_id')) |
|
548 |
|
549 def bug_id_for_attachment_id(self, attachment_id): |
|
550 self.authenticate() |
|
551 |
|
552 attachment_url = self.attachment_url_for_id(attachment_id, 'edit') |
|
553 log("Fetching: %s" % attachment_url) |
|
554 page = self.browser.open(attachment_url) |
|
555 return self._parse_bug_id_from_attachment_page(page) |
|
556 |
|
557 # FIXME: This should just return Attachment(id), which should be able to |
|
558 # lazily fetch needed data. |
|
559 |
|
560 def fetch_attachment(self, attachment_id): |
|
561 # We could grab all the attachment details off of the attachment edit |
|
562 # page but we already have working code to do so off of the bugs page, |
|
563 # so re-use that. |
|
564 bug_id = self.bug_id_for_attachment_id(attachment_id) |
|
565 if not bug_id: |
|
566 return None |
|
567 attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True) |
|
568 for attachment in attachments: |
|
569 if attachment.id() == int(attachment_id): |
|
570 return attachment |
|
571 return None # This should never be hit. |
|
572 |
|
573 def authenticate(self): |
|
574 if self.authenticated: |
|
575 return |
|
576 |
|
577 if self.dryrun: |
|
578 log("Skipping log in for dry run...") |
|
579 self.authenticated = True |
|
580 return |
|
581 |
|
582 attempts = 0 |
|
583 while not self.authenticated: |
|
584 attempts += 1 |
|
585 (username, password) = Credentials( |
|
586 self.bug_server_host, git_prefix="bugzilla").read_credentials() |
|
587 |
|
588 log("Logging in as %s..." % username) |
|
589 self.browser.open(self.bug_server_url + |
|
590 "index.cgi?GoAheadAndLogIn=1") |
|
591 self.browser.select_form(name="login") |
|
592 self.browser['Bugzilla_login'] = username |
|
593 self.browser['Bugzilla_password'] = password |
|
594 response = self.browser.submit() |
|
595 |
|
596 match = re.search("<title>(.+?)</title>", response.read()) |
|
597 # If the resulting page has a title, and it contains the word |
|
598 # "invalid" assume it's the login failure page. |
|
599 if match and re.search("Invalid", match.group(1), re.IGNORECASE): |
|
600 errorMessage = "Bugzilla login failed: %s" % match.group(1) |
|
601 # raise an exception only if this was the last attempt |
|
602 if attempts < 5: |
|
603 log(errorMessage) |
|
604 else: |
|
605 raise Exception(errorMessage) |
|
606 else: |
|
607 self.authenticated = True |
|
608 self.username = username |
|
609 |
|
610 def _fill_attachment_form(self, |
|
611 description, |
|
612 patch_file_object, |
|
613 comment_text=None, |
|
614 mark_for_review=False, |
|
615 mark_for_commit_queue=False, |
|
616 mark_for_landing=False, |
|
617 bug_id=None): |
|
618 self.browser['description'] = description |
|
619 self.browser['ispatch'] = ("1",) |
|
620 self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',) |
|
621 |
|
622 if mark_for_landing: |
|
623 self.browser['flag_type-3'] = ('+',) |
|
624 elif mark_for_commit_queue: |
|
625 self.browser['flag_type-3'] = ('?',) |
|
626 else: |
|
627 self.browser['flag_type-3'] = ('X',) |
|
628 |
|
629 if bug_id: |
|
630 patch_name = "bug-%s-%s.patch" % (bug_id, timestamp()) |
|
631 else: |
|
632 patch_name ="%s.patch" % timestamp() |
|
633 |
|
634 self.browser.add_file(patch_file_object, |
|
635 "text/plain", |
|
636 patch_name, |
|
637 'data') |
|
638 |
|
639 def add_patch_to_bug(self, |
|
640 bug_id, |
|
641 diff, |
|
642 description, |
|
643 comment_text=None, |
|
644 mark_for_review=False, |
|
645 mark_for_commit_queue=False, |
|
646 mark_for_landing=False): |
|
647 self.authenticate() |
|
648 |
|
649 log('Adding patch "%s" to %sshow_bug.cgi?id=%s' % (description, |
|
650 self.bug_server_url, |
|
651 bug_id)) |
|
652 |
|
653 if self.dryrun: |
|
654 log(comment_text) |
|
655 return |
|
656 |
|
657 self.browser.open("%sattachment.cgi?action=enter&bugid=%s" % ( |
|
658 self.bug_server_url, bug_id)) |
|
659 self.browser.select_form(name="entryform") |
|
660 |
|
661 # _fill_attachment_form expects a file-like object |
|
662 # Patch files are already binary, so no encoding needed. |
|
663 assert(isinstance(diff, str)) |
|
664 patch_file_object = StringIO.StringIO(diff) |
|
665 self._fill_attachment_form(description, |
|
666 patch_file_object, |
|
667 mark_for_review=mark_for_review, |
|
668 mark_for_commit_queue=mark_for_commit_queue, |
|
669 mark_for_landing=mark_for_landing, |
|
670 bug_id=bug_id) |
|
671 if comment_text: |
|
672 log(comment_text) |
|
673 self.browser['comment'] = comment_text |
|
674 self.browser.submit() |
|
675 |
|
676 def _check_create_bug_response(self, response_html): |
|
677 match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>", |
|
678 response_html) |
|
679 if match: |
|
680 return match.group('bug_id') |
|
681 |
|
682 match = re.search( |
|
683 '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">', |
|
684 response_html, |
|
685 re.DOTALL) |
|
686 error_message = "FAIL" |
|
687 if match: |
|
688 text_lines = BeautifulSoup( |
|
689 match.group('error_message')).findAll(text=True) |
|
690 error_message = "\n" + '\n'.join( |
|
691 [" " + line.strip() |
|
692 for line in text_lines if line.strip()]) |
|
693 raise Exception("Bug not created: %s" % error_message) |
|
694 |
|
695 def create_bug(self, |
|
696 bug_title, |
|
697 bug_description, |
|
698 component=None, |
|
699 diff=None, |
|
700 patch_description=None, |
|
701 cc=None, |
|
702 blocked=None, |
|
703 assignee=None, |
|
704 mark_for_review=False, |
|
705 mark_for_commit_queue=False): |
|
706 self.authenticate() |
|
707 |
|
708 log('Creating bug with title "%s"' % bug_title) |
|
709 if self.dryrun: |
|
710 log(bug_description) |
|
711 return |
|
712 |
|
713 self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit") |
|
714 self.browser.select_form(name="Create") |
|
715 component_items = self.browser.find_control('component').items |
|
716 component_names = map(lambda item: item.name, component_items) |
|
717 if not component: |
|
718 component = "New Bugs" |
|
719 if component not in component_names: |
|
720 component = User.prompt_with_list("Please pick a component:", component_names) |
|
721 self.browser["component"] = [component] |
|
722 if cc: |
|
723 self.browser["cc"] = cc |
|
724 if blocked: |
|
725 self.browser["blocked"] = unicode(blocked) |
|
726 if assignee == None: |
|
727 assignee = self.username |
|
728 if assignee and not self.browser.find_control("assigned_to").disabled: |
|
729 self.browser["assigned_to"] = assignee |
|
730 self.browser["short_desc"] = bug_title |
|
731 self.browser["comment"] = bug_description |
|
732 |
|
733 if diff: |
|
734 # _fill_attachment_form expects a file-like object |
|
735 # Patch files are already binary, so no encoding needed. |
|
736 assert(isinstance(diff, str)) |
|
737 patch_file_object = StringIO.StringIO(diff) |
|
738 self._fill_attachment_form( |
|
739 patch_description, |
|
740 patch_file_object, |
|
741 mark_for_review=mark_for_review, |
|
742 mark_for_commit_queue=mark_for_commit_queue) |
|
743 |
|
744 response = self.browser.submit() |
|
745 |
|
746 bug_id = self._check_create_bug_response(response.read()) |
|
747 log("Bug %s created." % bug_id) |
|
748 log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id)) |
|
749 return bug_id |
|
750 |
|
751 def _find_select_element_for_flag(self, flag_name): |
|
752 # FIXME: This will break if we ever re-order attachment flags |
|
753 if flag_name == "review": |
|
754 return self.browser.find_control(type='select', nr=0) |
|
755 elif flag_name == "commit-queue": |
|
756 return self.browser.find_control(type='select', nr=1) |
|
757 elif flag_name == "in-rietveld": |
|
758 return self.browser.find_control(type='select', nr=2) |
|
759 raise Exception("Don't know how to find flag named \"%s\"" % flag_name) |
|
760 |
|
761 def clear_attachment_flags(self, |
|
762 attachment_id, |
|
763 additional_comment_text=None): |
|
764 self.authenticate() |
|
765 |
|
766 comment_text = "Clearing flags on attachment: %s" % attachment_id |
|
767 if additional_comment_text: |
|
768 comment_text += "\n\n%s" % additional_comment_text |
|
769 log(comment_text) |
|
770 |
|
771 if self.dryrun: |
|
772 return |
|
773 |
|
774 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) |
|
775 self.browser.select_form(nr=1) |
|
776 self.browser.set_value(comment_text, name='comment', nr=0) |
|
777 self._find_select_element_for_flag('review').value = ("X",) |
|
778 self._find_select_element_for_flag('commit-queue').value = ("X",) |
|
779 self.browser.submit() |
|
780 |
|
781 def set_flag_on_attachment(self, |
|
782 attachment_id, |
|
783 flag_name, |
|
784 flag_value, |
|
785 comment_text=None, |
|
786 additional_comment_text=None): |
|
787 # FIXME: We need a way to test this function on a live bugzilla |
|
788 # instance. |
|
789 |
|
790 self.authenticate() |
|
791 |
|
792 if additional_comment_text: |
|
793 comment_text += "\n\n%s" % additional_comment_text |
|
794 log(comment_text) |
|
795 |
|
796 if self.dryrun: |
|
797 return |
|
798 |
|
799 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) |
|
800 self.browser.select_form(nr=1) |
|
801 |
|
802 if comment_text: |
|
803 self.browser.set_value(comment_text, name='comment', nr=0) |
|
804 |
|
805 self._find_select_element_for_flag(flag_name).value = (flag_value,) |
|
806 self.browser.submit() |
|
807 |
|
808 # FIXME: All of these bug editing methods have a ridiculous amount of |
|
809 # copy/paste code. |
|
810 |
|
811 def obsolete_attachment(self, attachment_id, comment_text=None): |
|
812 self.authenticate() |
|
813 |
|
814 log("Obsoleting attachment: %s" % attachment_id) |
|
815 if self.dryrun: |
|
816 log(comment_text) |
|
817 return |
|
818 |
|
819 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) |
|
820 self.browser.select_form(nr=1) |
|
821 self.browser.find_control('isobsolete').items[0].selected = True |
|
822 # Also clear any review flag (to remove it from review/commit queues) |
|
823 self._find_select_element_for_flag('review').value = ("X",) |
|
824 self._find_select_element_for_flag('commit-queue').value = ("X",) |
|
825 if comment_text: |
|
826 log(comment_text) |
|
827 # Bugzilla has two textareas named 'comment', one is somehow |
|
828 # hidden. We want the first. |
|
829 self.browser.set_value(comment_text, name='comment', nr=0) |
|
830 self.browser.submit() |
|
831 |
|
832 def add_cc_to_bug(self, bug_id, email_address_list): |
|
833 self.authenticate() |
|
834 |
|
835 log("Adding %s to the CC list for bug %s" % (email_address_list, |
|
836 bug_id)) |
|
837 if self.dryrun: |
|
838 return |
|
839 |
|
840 self.browser.open(self.bug_url_for_bug_id(bug_id)) |
|
841 self.browser.select_form(name="changeform") |
|
842 self.browser["newcc"] = ", ".join(email_address_list) |
|
843 self.browser.submit() |
|
844 |
|
845 def post_comment_to_bug(self, bug_id, comment_text, cc=None): |
|
846 self.authenticate() |
|
847 |
|
848 log("Adding comment to bug %s" % bug_id) |
|
849 if self.dryrun: |
|
850 log(comment_text) |
|
851 return |
|
852 |
|
853 self.browser.open(self.bug_url_for_bug_id(bug_id)) |
|
854 self.browser.select_form(name="changeform") |
|
855 self.browser["comment"] = comment_text |
|
856 if cc: |
|
857 self.browser["newcc"] = ", ".join(cc) |
|
858 self.browser.submit() |
|
859 |
|
860 def close_bug_as_fixed(self, bug_id, comment_text=None): |
|
861 self.authenticate() |
|
862 |
|
863 log("Closing bug %s as fixed" % bug_id) |
|
864 if self.dryrun: |
|
865 log(comment_text) |
|
866 return |
|
867 |
|
868 self.browser.open(self.bug_url_for_bug_id(bug_id)) |
|
869 self.browser.select_form(name="changeform") |
|
870 if comment_text: |
|
871 log(comment_text) |
|
872 self.browser['comment'] = comment_text |
|
873 self.browser['bug_status'] = ['RESOLVED'] |
|
874 self.browser['resolution'] = ['FIXED'] |
|
875 self.browser.submit() |
|
876 |
|
877 def reassign_bug(self, bug_id, assignee, comment_text=None): |
|
878 self.authenticate() |
|
879 |
|
880 log("Assigning bug %s to %s" % (bug_id, assignee)) |
|
881 if self.dryrun: |
|
882 log(comment_text) |
|
883 return |
|
884 |
|
885 self.browser.open(self.bug_url_for_bug_id(bug_id)) |
|
886 self.browser.select_form(name="changeform") |
|
887 if comment_text: |
|
888 log(comment_text) |
|
889 self.browser["comment"] = comment_text |
|
890 self.browser["assigned_to"] = assignee |
|
891 self.browser.submit() |
|
892 |
|
893 def reopen_bug(self, bug_id, comment_text): |
|
894 self.authenticate() |
|
895 |
|
896 log("Re-opening bug %s" % bug_id) |
|
897 # Bugzilla requires a comment when re-opening a bug, so we know it will |
|
898 # never be None. |
|
899 log(comment_text) |
|
900 if self.dryrun: |
|
901 return |
|
902 |
|
903 self.browser.open(self.bug_url_for_bug_id(bug_id)) |
|
904 self.browser.select_form(name="changeform") |
|
905 bug_status = self.browser.find_control("bug_status", type="select") |
|
906 # This is a hack around the fact that ClientForm.ListControl seems to |
|
907 # have no simpler way to ask if a control has an item named "REOPENED" |
|
908 # without using exceptions for control flow. |
|
909 possible_bug_statuses = map(lambda item: item.name, bug_status.items) |
|
910 if "REOPENED" in possible_bug_statuses: |
|
911 bug_status.value = ["REOPENED"] |
|
912 # If the bug was never confirmed it will not have a "REOPENED" |
|
913 # state, but only an "UNCONFIRMED" state. |
|
914 elif "UNCONFIRMED" in possible_bug_statuses: |
|
915 bug_status.value = ["UNCONFIRMED"] |
|
916 else: |
|
917 # FIXME: This logic is slightly backwards. We won't print this |
|
918 # message if the bug is already open with state "UNCONFIRMED". |
|
919 log("Did not reopen bug %s, it appears to already be open with status %s." % (bug_id, bug_status.value)) |
|
920 self.browser['comment'] = comment_text |
|
921 self.browser.submit() |