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 mimetypes 34 import os.path 35 import re 36 import StringIO 37 import urllib 38 39 from datetime import datetime # used in timestamp() 40 41 from .attachment import Attachment 42 from .bug import Bug 43 44 from webkitpy.common.system.deprecated_logging import log 45 from webkitpy.common.config import committers 46 from webkitpy.common.net.credentials import Credentials 47 from webkitpy.common.system.user import User 48 from webkitpy.thirdparty.autoinstalled.mechanize import Browser 49 from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, BeautifulStoneSoup, SoupStrainer 50 51 52 # FIXME: parse_bug_id should not be a free function. 53 def parse_bug_id(message): 54 if not message: 55 return None 56 match = re.search(Bugzilla.bug_url_short, message) 57 if match: 58 return int(match.group('bug_id')) 59 match = re.search(Bugzilla.bug_url_long, message) 60 if match: 61 return int(match.group('bug_id')) 62 return None 63 64 65 # FIXME: parse_bug_id_from_changelog should not be a free function. 66 # Parse the bug ID out of a Changelog message based on the format that is 67 # used by prepare-ChangeLog 68 def parse_bug_id_from_changelog(message): 69 if not message: 70 return None 71 match = re.search("^\s*" + Bugzilla.bug_url_short + "$", message, re.MULTILINE) 72 if match: 73 return int(match.group('bug_id')) 74 match = re.search("^\s*" + Bugzilla.bug_url_long + "$", message, re.MULTILINE) 75 if match: 76 return int(match.group('bug_id')) 77 # We weren't able to find a bug URL in the format used by prepare-ChangeLog. Fall back to the 78 # first bug URL found anywhere in the message. 79 return parse_bug_id(message) 80 81 def timestamp(): 82 return datetime.now().strftime("%Y%m%d%H%M%S") 83 84 85 # A container for all of the logic for making and parsing bugzilla queries. 86 class BugzillaQueries(object): 87 88 def __init__(self, bugzilla): 89 self._bugzilla = bugzilla 90 91 def _is_xml_bugs_form(self, form): 92 # ClientForm.HTMLForm.find_control throws if the control is not found, 93 # so we do a manual search instead: 94 return "xml" in [control.id for control in form.controls] 95 96 # This is kinda a hack. There is probably a better way to get this information from bugzilla. 97 def _parse_result_count(self, results_page): 98 result_count_text = BeautifulSoup(results_page).find(attrs={'class': 'bz_result_count'}).string 99 result_count_parts = result_count_text.strip().split(" ") 100 if result_count_parts[0] == "Zarro": 101 return 0 102 if result_count_parts[0] == "One": 103 return 1 104 return int(result_count_parts[0]) 105 106 # Note: _load_query, _fetch_bug and _fetch_bugs_from_advanced_query 107 # are the only methods which access self._bugzilla. 108 109 def _load_query(self, query): 110 self._bugzilla.authenticate() 111 full_url = "%s%s" % (self._bugzilla.bug_server_url, query) 112 return self._bugzilla.browser.open(full_url) 113 114 def _fetch_bugs_from_advanced_query(self, query): 115 results_page = self._load_query(query) 116 if not self._parse_result_count(results_page): 117 return [] 118 # Bugzilla results pages have an "XML" submit button at the bottom 119 # which can be used to get an XML page containing all of the <bug> elements. 120 # This is slighty lame that this assumes that _load_query used 121 # self._bugzilla.browser and that it's in an acceptable state. 122 self._bugzilla.browser.select_form(predicate=self._is_xml_bugs_form) 123 bugs_xml = self._bugzilla.browser.submit() 124 return self._bugzilla._parse_bugs_from_xml(bugs_xml) 125 126 def _fetch_bug(self, bug_id): 127 return self._bugzilla.fetch_bug(bug_id) 128 129 def _fetch_bug_ids_advanced_query(self, query): 130 soup = BeautifulSoup(self._load_query(query)) 131 # The contents of the <a> inside the cells in the first column happen 132 # to be the bug id. 133 return [int(bug_link_cell.find("a").string) 134 for bug_link_cell in soup('td', "first-child")] 135 136 def _parse_attachment_ids_request_query(self, page): 137 digits = re.compile("\d+") 138 attachment_href = re.compile("attachment.cgi\?id=\d+&action=review") 139 attachment_links = SoupStrainer("a", href=attachment_href) 140 return [int(digits.search(tag["href"]).group(0)) 141 for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)] 142 143 def _fetch_attachment_ids_request_query(self, query): 144 return self._parse_attachment_ids_request_query(self._load_query(query)) 145 146 def _parse_quips(self, page): 147 soup = BeautifulSoup(page, convertEntities=BeautifulSoup.HTML_ENTITIES) 148 quips = soup.find(text=re.compile(r"Existing quips:")).findNext("ul").findAll("li") 149 return [unicode(quip_entry.string) for quip_entry in quips] 150 151 def fetch_quips(self): 152 return self._parse_quips(self._load_query("/quips.cgi?action=show")) 153 154 # List of all r+'d bugs. 155 def fetch_bug_ids_from_pending_commit_list(self): 156 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" 157 return self._fetch_bug_ids_advanced_query(needs_commit_query_url) 158 159 def fetch_bugs_matching_quicksearch(self, search_string): 160 # We may want to use a more explicit query than "quicksearch". 161 # If quicksearch changes we should probably change to use 162 # a normal buglist.cgi?query_format=advanced query. 163 quicksearch_url = "buglist.cgi?quicksearch=%s" % urllib.quote(search_string) 164 return self._fetch_bugs_from_advanced_query(quicksearch_url) 165 166 # Currently this returns all bugs across all components. 167 # In the future we may wish to extend this API to construct more restricted searches. 168 def fetch_bugs_matching_search(self, search_string, author_email=None): 169 query = "buglist.cgi?query_format=advanced" 170 if search_string: 171 query += "&short_desc_type=allwordssubstr&short_desc=%s" % urllib.quote(search_string) 172 if author_email: 173 query += "&emailreporter1=1&emailtype1=substring&email1=%s" % urllib.quote(search_string) 174 return self._fetch_bugs_from_advanced_query(query) 175 176 def fetch_patches_from_pending_commit_list(self): 177 return sum([self._fetch_bug(bug_id).reviewed_patches() 178 for bug_id in self.fetch_bug_ids_from_pending_commit_list()], []) 179 180 def fetch_bug_ids_from_commit_queue(self): 181 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" 182 return self._fetch_bug_ids_advanced_query(commit_queue_url) 183 184 def fetch_patches_from_commit_queue(self): 185 # This function will only return patches which have valid committers 186 # set. It won't reject patches with invalid committers/reviewers. 187 return sum([self._fetch_bug(bug_id).commit_queued_patches() 188 for bug_id in self.fetch_bug_ids_from_commit_queue()], []) 189 190 def fetch_bug_ids_from_review_queue(self): 191 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?" 192 return self._fetch_bug_ids_advanced_query(review_queue_url) 193 194 # This method will make several requests to bugzilla. 195 def fetch_patches_from_review_queue(self, limit=None): 196 # [:None] returns the whole array. 197 return sum([self._fetch_bug(bug_id).unreviewed_patches() 198 for bug_id in self.fetch_bug_ids_from_review_queue()[:limit]], []) 199 200 # NOTE: This is the only client of _fetch_attachment_ids_request_query 201 # This method only makes one request to bugzilla. 202 def fetch_attachment_ids_from_review_queue(self): 203 review_queue_url = "request.cgi?action=queue&type=review&group=type" 204 return self._fetch_attachment_ids_request_query(review_queue_url) 205 206 207 class Bugzilla(object): 208 209 def __init__(self, dryrun=False, committers=committers.CommitterList()): 210 self.dryrun = dryrun 211 self.authenticated = False 212 self.queries = BugzillaQueries(self) 213 self.committers = committers 214 self.cached_quips = [] 215 216 # FIXME: We should use some sort of Browser mock object when in dryrun 217 # mode (to prevent any mistakes). 218 self.browser = Browser() 219 # Ignore bugs.webkit.org/robots.txt until we fix it to allow this 220 # script. 221 self.browser.set_handle_robots(False) 222 223 # FIXME: Much of this should go into some sort of config module, 224 # such as common.config.urls. 225 bug_server_host = "bugs.webkit.org" 226 bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host) 227 bug_server_url = "https://%s/" % bug_server_host 228 bug_url_long = bug_server_regex + r"show_bug\.cgi\?id=(?P<bug_id>\d+)(&ctype=xml)?" 229 bug_url_short = r"http\://webkit\.org/b/(?P<bug_id>\d+)" 230 231 def quips(self): 232 # We only fetch and parse the list of quips once per instantiation 233 # so that we do not burden bugs.webkit.org. 234 if not self.cached_quips and not self.dryrun: 235 self.cached_quips = self.queries.fetch_quips() 236 return self.cached_quips 237 238 def bug_url_for_bug_id(self, bug_id, xml=False): 239 if not bug_id: 240 return None 241 content_type = "&ctype=xml" if xml else "" 242 return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url, bug_id, content_type) 243 244 def short_bug_url_for_bug_id(self, bug_id): 245 if not bug_id: 246 return None 247 return "http://webkit.org/b/%s" % bug_id 248 249 def add_attachment_url(self, bug_id): 250 return "%sattachment.cgi?action=enter&bugid=%s" % (self.bug_server_url, bug_id) 251 252 def attachment_url_for_id(self, attachment_id, action="view"): 253 if not attachment_id: 254 return None 255 action_param = "" 256 if action and action != "view": 257 action_param = "&action=%s" % action 258 return "%sattachment.cgi?id=%s%s" % (self.bug_server_url, 259 attachment_id, 260 action_param) 261 262 def _parse_attachment_flag(self, 263 element, 264 flag_name, 265 attachment, 266 result_key): 267 flag = element.find('flag', attrs={'name': flag_name}) 268 if flag: 269 attachment[flag_name] = flag['status'] 270 if flag['status'] == '+': 271 attachment[result_key] = flag['setter'] 272 # Sadly show_bug.cgi?ctype=xml does not expose the flag modification date. 273 274 def _string_contents(self, soup): 275 # WebKit's bugzilla instance uses UTF-8. 276 # BeautifulStoneSoup always returns Unicode strings, however 277 # the .string method returns a (unicode) NavigableString. 278 # NavigableString can confuse other parts of the code, so we 279 # convert from NavigableString to a real unicode() object using unicode(). 280 return unicode(soup.string) 281 282 # Example: 2010-01-20 14:31 PST 283 # FIXME: Some bugzilla dates seem to have seconds in them? 284 # Python does not support timezones out of the box. 285 # Assume that bugzilla always uses PST (which is true for bugs.webkit.org) 286 _bugzilla_date_format = "%Y-%m-%d %H:%M" 287 288 @classmethod 289 def _parse_date(cls, date_string): 290 (date, time, time_zone) = date_string.split(" ") 291 # Ignore the timezone because python doesn't understand timezones out of the box. 292 date_string = "%s %s" % (date, time) 293 return datetime.strptime(date_string, cls._bugzilla_date_format) 294 295 def _date_contents(self, soup): 296 return self._parse_date(self._string_contents(soup)) 297 298 def _parse_attachment_element(self, element, bug_id): 299 attachment = {} 300 attachment['bug_id'] = bug_id 301 attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1") 302 attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1") 303 attachment['id'] = int(element.find('attachid').string) 304 # FIXME: No need to parse out the url here. 305 attachment['url'] = self.attachment_url_for_id(attachment['id']) 306 attachment["attach_date"] = self._date_contents(element.find("date")) 307 attachment['name'] = self._string_contents(element.find('desc')) 308 attachment['attacher_email'] = self._string_contents(element.find('attacher')) 309 attachment['type'] = self._string_contents(element.find('type')) 310 self._parse_attachment_flag( 311 element, 'review', attachment, 'reviewer_email') 312 self._parse_attachment_flag( 313 element, 'commit-queue', attachment, 'committer_email') 314 return attachment 315 316 def _parse_bugs_from_xml(self, page): 317 soup = BeautifulSoup(page) 318 # Without the unicode() call, BeautifulSoup occasionally complains of being 319 # passed None for no apparent reason. 320 return [Bug(self._parse_bug_dictionary_from_xml(unicode(bug_xml)), self) for bug_xml in soup('bug')] 321 322 def _parse_bug_dictionary_from_xml(self, page): 323 soup = BeautifulStoneSoup(page, convertEntities=BeautifulStoneSoup.XML_ENTITIES) 324 bug = {} 325 bug["id"] = int(soup.find("bug_id").string) 326 bug["title"] = self._string_contents(soup.find("short_desc")) 327 bug["bug_status"] = self._string_contents(soup.find("bug_status")) 328 dup_id = soup.find("dup_id") 329 if dup_id: 330 bug["dup_id"] = self._string_contents(dup_id) 331 bug["reporter_email"] = self._string_contents(soup.find("reporter")) 332 bug["assigned_to_email"] = self._string_contents(soup.find("assigned_to")) 333 bug["cc_emails"] = [self._string_contents(element) for element in soup.findAll('cc')] 334 bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')] 335 return bug 336 337 # Makes testing fetch_*_from_bug() possible until we have a better 338 # BugzillaNetwork abstration. 339 340 def _fetch_bug_page(self, bug_id): 341 bug_url = self.bug_url_for_bug_id(bug_id, xml=True) 342 log("Fetching: %s" % bug_url) 343 return self.browser.open(bug_url) 344 345 def fetch_bug_dictionary(self, bug_id): 346 try: 347 return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id)) 348 except KeyboardInterrupt: 349 raise 350 except: 351 self.authenticate() 352 return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id)) 353 354 # FIXME: A BugzillaCache object should provide all these fetch_ methods. 355 356 def fetch_bug(self, bug_id): 357 return Bug(self.fetch_bug_dictionary(bug_id), self) 358 359 def fetch_attachment_contents(self, attachment_id): 360 attachment_url = self.attachment_url_for_id(attachment_id) 361 # We need to authenticate to download patches from security bugs. 362 self.authenticate() 363 return self.browser.open(attachment_url).read() 364 365 def _parse_bug_id_from_attachment_page(self, page): 366 # The "Up" relation happens to point to the bug. 367 up_link = BeautifulSoup(page).find('link', rel='Up') 368 if not up_link: 369 # This attachment does not exist (or you don't have permissions to 370 # view it). 371 return None 372 match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href']) 373 return int(match.group('bug_id')) 374 375 def bug_id_for_attachment_id(self, attachment_id): 376 self.authenticate() 377 378 attachment_url = self.attachment_url_for_id(attachment_id, 'edit') 379 log("Fetching: %s" % attachment_url) 380 page = self.browser.open(attachment_url) 381 return self._parse_bug_id_from_attachment_page(page) 382 383 # FIXME: This should just return Attachment(id), which should be able to 384 # lazily fetch needed data. 385 386 def fetch_attachment(self, attachment_id): 387 # We could grab all the attachment details off of the attachment edit 388 # page but we already have working code to do so off of the bugs page, 389 # so re-use that. 390 bug_id = self.bug_id_for_attachment_id(attachment_id) 391 if not bug_id: 392 return None 393 attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True) 394 for attachment in attachments: 395 if attachment.id() == int(attachment_id): 396 return attachment 397 return None # This should never be hit. 398 399 def authenticate(self): 400 if self.authenticated: 401 return 402 403 if self.dryrun: 404 log("Skipping log in for dry run...") 405 self.authenticated = True 406 return 407 408 credentials = Credentials(self.bug_server_host, git_prefix="bugzilla") 409 410 attempts = 0 411 while not self.authenticated: 412 attempts += 1 413 username, password = credentials.read_credentials() 414 415 log("Logging in as %s..." % username) 416 self.browser.open(self.bug_server_url + 417 "index.cgi?GoAheadAndLogIn=1") 418 self.browser.select_form(name="login") 419 self.browser['Bugzilla_login'] = username 420 self.browser['Bugzilla_password'] = password 421 response = self.browser.submit() 422 423 match = re.search("<title>(.+?)</title>", response.read()) 424 # If the resulting page has a title, and it contains the word 425 # "invalid" assume it's the login failure page. 426 if match and re.search("Invalid", match.group(1), re.IGNORECASE): 427 errorMessage = "Bugzilla login failed: %s" % match.group(1) 428 # raise an exception only if this was the last attempt 429 if attempts < 5: 430 log(errorMessage) 431 else: 432 raise Exception(errorMessage) 433 else: 434 self.authenticated = True 435 self.username = username 436 437 def _commit_queue_flag(self, mark_for_landing, mark_for_commit_queue): 438 if mark_for_landing: 439 return '+' 440 elif mark_for_commit_queue: 441 return '?' 442 return 'X' 443 444 # FIXME: mark_for_commit_queue and mark_for_landing should be joined into a single commit_flag argument. 445 def _fill_attachment_form(self, 446 description, 447 file_object, 448 mark_for_review=False, 449 mark_for_commit_queue=False, 450 mark_for_landing=False, 451 is_patch=False, 452 filename=None, 453 mimetype=None): 454 self.browser['description'] = description 455 if is_patch: 456 self.browser['ispatch'] = ("1",) 457 # FIXME: Should this use self._find_select_element_for_flag? 458 self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',) 459 self.browser['flag_type-3'] = (self._commit_queue_flag(mark_for_landing, mark_for_commit_queue),) 460 461 filename = filename or "%s.patch" % timestamp() 462 if not mimetype: 463 mimetypes.add_type('text/plain', '.patch') # Make sure mimetypes knows about .patch 464 mimetype, _ = mimetypes.guess_type(filename) 465 if not mimetype: 466 mimetype = "text/plain" # Bugzilla might auto-guess for us and we might not need this? 467 self.browser.add_file(file_object, mimetype, filename, 'data') 468 469 def _file_object_for_upload(self, file_or_string): 470 if hasattr(file_or_string, 'read'): 471 return file_or_string 472 # Only if file_or_string is not already encoded do we want to encode it. 473 if isinstance(file_or_string, unicode): 474 file_or_string = file_or_string.encode('utf-8') 475 return StringIO.StringIO(file_or_string) 476 477 # timestamp argument is just for unittests. 478 def _filename_for_upload(self, file_object, bug_id, extension="txt", timestamp=timestamp): 479 if hasattr(file_object, "name"): 480 return file_object.name 481 return "bug-%s-%s.%s" % (bug_id, timestamp(), extension) 482 483 def add_attachment_to_bug(self, 484 bug_id, 485 file_or_string, 486 description, 487 filename=None, 488 comment_text=None): 489 self.authenticate() 490 log('Adding attachment "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id))) 491 if self.dryrun: 492 log(comment_text) 493 return 494 495 self.browser.open(self.add_attachment_url(bug_id)) 496 self.browser.select_form(name="entryform") 497 file_object = self._file_object_for_upload(file_or_string) 498 filename = filename or self._filename_for_upload(file_object, bug_id) 499 self._fill_attachment_form(description, file_object, filename=filename) 500 if comment_text: 501 log(comment_text) 502 self.browser['comment'] = comment_text 503 self.browser.submit() 504 505 # FIXME: The arguments to this function should be simplified and then 506 # this should be merged into add_attachment_to_bug 507 def add_patch_to_bug(self, 508 bug_id, 509 file_or_string, 510 description, 511 comment_text=None, 512 mark_for_review=False, 513 mark_for_commit_queue=False, 514 mark_for_landing=False): 515 self.authenticate() 516 log('Adding patch "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id))) 517 518 if self.dryrun: 519 log(comment_text) 520 return 521 522 self.browser.open(self.add_attachment_url(bug_id)) 523 self.browser.select_form(name="entryform") 524 file_object = self._file_object_for_upload(file_or_string) 525 filename = self._filename_for_upload(file_object, bug_id, extension="patch") 526 self._fill_attachment_form(description, 527 file_object, 528 mark_for_review=mark_for_review, 529 mark_for_commit_queue=mark_for_commit_queue, 530 mark_for_landing=mark_for_landing, 531 is_patch=True, 532 filename=filename) 533 if comment_text: 534 log(comment_text) 535 self.browser['comment'] = comment_text 536 self.browser.submit() 537 538 # FIXME: There has to be a more concise way to write this method. 539 def _check_create_bug_response(self, response_html): 540 match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>", 541 response_html) 542 if match: 543 return match.group('bug_id') 544 545 match = re.search( 546 '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">', 547 response_html, 548 re.DOTALL) 549 error_message = "FAIL" 550 if match: 551 text_lines = BeautifulSoup( 552 match.group('error_message')).findAll(text=True) 553 error_message = "\n" + '\n'.join( 554 [" " + line.strip() 555 for line in text_lines if line.strip()]) 556 raise Exception("Bug not created: %s" % error_message) 557 558 def create_bug(self, 559 bug_title, 560 bug_description, 561 component=None, 562 diff=None, 563 patch_description=None, 564 cc=None, 565 blocked=None, 566 assignee=None, 567 mark_for_review=False, 568 mark_for_commit_queue=False): 569 self.authenticate() 570 571 log('Creating bug with title "%s"' % bug_title) 572 if self.dryrun: 573 log(bug_description) 574 # FIXME: This will make some paths fail, as they assume this returns an id. 575 return 576 577 self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit") 578 self.browser.select_form(name="Create") 579 component_items = self.browser.find_control('component').items 580 component_names = map(lambda item: item.name, component_items) 581 if not component: 582 component = "New Bugs" 583 if component not in component_names: 584 component = User.prompt_with_list("Please pick a component:", component_names) 585 self.browser["component"] = [component] 586 if cc: 587 self.browser["cc"] = cc 588 if blocked: 589 self.browser["blocked"] = unicode(blocked) 590 if not assignee: 591 assignee = self.username 592 if assignee and not self.browser.find_control("assigned_to").disabled: 593 self.browser["assigned_to"] = assignee 594 self.browser["short_desc"] = bug_title 595 self.browser["comment"] = bug_description 596 597 if diff: 598 # _fill_attachment_form expects a file-like object 599 # Patch files are already binary, so no encoding needed. 600 assert(isinstance(diff, str)) 601 patch_file_object = StringIO.StringIO(diff) 602 self._fill_attachment_form( 603 patch_description, 604 patch_file_object, 605 mark_for_review=mark_for_review, 606 mark_for_commit_queue=mark_for_commit_queue, 607 is_patch=True) 608 609 response = self.browser.submit() 610 611 bug_id = self._check_create_bug_response(response.read()) 612 log("Bug %s created." % bug_id) 613 log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id)) 614 return bug_id 615 616 def _find_select_element_for_flag(self, flag_name): 617 # FIXME: This will break if we ever re-order attachment flags 618 if flag_name == "review": 619 return self.browser.find_control(type='select', nr=0) 620 elif flag_name == "commit-queue": 621 return self.browser.find_control(type='select', nr=1) 622 raise Exception("Don't know how to find flag named \"%s\"" % flag_name) 623 624 def clear_attachment_flags(self, 625 attachment_id, 626 additional_comment_text=None): 627 self.authenticate() 628 629 comment_text = "Clearing flags on attachment: %s" % attachment_id 630 if additional_comment_text: 631 comment_text += "\n\n%s" % additional_comment_text 632 log(comment_text) 633 634 if self.dryrun: 635 return 636 637 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) 638 self.browser.select_form(nr=1) 639 self.browser.set_value(comment_text, name='comment', nr=0) 640 self._find_select_element_for_flag('review').value = ("X",) 641 self._find_select_element_for_flag('commit-queue').value = ("X",) 642 self.browser.submit() 643 644 def set_flag_on_attachment(self, 645 attachment_id, 646 flag_name, 647 flag_value, 648 comment_text=None, 649 additional_comment_text=None): 650 # FIXME: We need a way to test this function on a live bugzilla 651 # instance. 652 653 self.authenticate() 654 655 if additional_comment_text: 656 comment_text += "\n\n%s" % additional_comment_text 657 log(comment_text) 658 659 if self.dryrun: 660 return 661 662 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) 663 self.browser.select_form(nr=1) 664 665 if comment_text: 666 self.browser.set_value(comment_text, name='comment', nr=0) 667 668 self._find_select_element_for_flag(flag_name).value = (flag_value,) 669 self.browser.submit() 670 671 # FIXME: All of these bug editing methods have a ridiculous amount of 672 # copy/paste code. 673 674 def obsolete_attachment(self, attachment_id, comment_text=None): 675 self.authenticate() 676 677 log("Obsoleting attachment: %s" % attachment_id) 678 if self.dryrun: 679 log(comment_text) 680 return 681 682 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) 683 self.browser.select_form(nr=1) 684 self.browser.find_control('isobsolete').items[0].selected = True 685 # Also clear any review flag (to remove it from review/commit queues) 686 self._find_select_element_for_flag('review').value = ("X",) 687 self._find_select_element_for_flag('commit-queue').value = ("X",) 688 if comment_text: 689 log(comment_text) 690 # Bugzilla has two textareas named 'comment', one is somehow 691 # hidden. We want the first. 692 self.browser.set_value(comment_text, name='comment', nr=0) 693 self.browser.submit() 694 695 def add_cc_to_bug(self, bug_id, email_address_list): 696 self.authenticate() 697 698 log("Adding %s to the CC list for bug %s" % (email_address_list, 699 bug_id)) 700 if self.dryrun: 701 return 702 703 self.browser.open(self.bug_url_for_bug_id(bug_id)) 704 self.browser.select_form(name="changeform") 705 self.browser["newcc"] = ", ".join(email_address_list) 706 self.browser.submit() 707 708 def post_comment_to_bug(self, bug_id, comment_text, cc=None): 709 self.authenticate() 710 711 log("Adding comment to bug %s" % bug_id) 712 if self.dryrun: 713 log(comment_text) 714 return 715 716 self.browser.open(self.bug_url_for_bug_id(bug_id)) 717 self.browser.select_form(name="changeform") 718 self.browser["comment"] = comment_text 719 if cc: 720 self.browser["newcc"] = ", ".join(cc) 721 self.browser.submit() 722 723 def close_bug_as_fixed(self, bug_id, comment_text=None): 724 self.authenticate() 725 726 log("Closing bug %s as fixed" % bug_id) 727 if self.dryrun: 728 log(comment_text) 729 return 730 731 self.browser.open(self.bug_url_for_bug_id(bug_id)) 732 self.browser.select_form(name="changeform") 733 if comment_text: 734 self.browser['comment'] = comment_text 735 self.browser['bug_status'] = ['RESOLVED'] 736 self.browser['resolution'] = ['FIXED'] 737 self.browser.submit() 738 739 def reassign_bug(self, bug_id, assignee, comment_text=None): 740 self.authenticate() 741 742 log("Assigning bug %s to %s" % (bug_id, assignee)) 743 if self.dryrun: 744 log(comment_text) 745 return 746 747 self.browser.open(self.bug_url_for_bug_id(bug_id)) 748 self.browser.select_form(name="changeform") 749 if comment_text: 750 log(comment_text) 751 self.browser["comment"] = comment_text 752 self.browser["assigned_to"] = assignee 753 self.browser.submit() 754 755 def reopen_bug(self, bug_id, comment_text): 756 self.authenticate() 757 758 log("Re-opening bug %s" % bug_id) 759 # Bugzilla requires a comment when re-opening a bug, so we know it will 760 # never be None. 761 log(comment_text) 762 if self.dryrun: 763 return 764 765 self.browser.open(self.bug_url_for_bug_id(bug_id)) 766 self.browser.select_form(name="changeform") 767 bug_status = self.browser.find_control("bug_status", type="select") 768 # This is a hack around the fact that ClientForm.ListControl seems to 769 # have no simpler way to ask if a control has an item named "REOPENED" 770 # without using exceptions for control flow. 771 possible_bug_statuses = map(lambda item: item.name, bug_status.items) 772 if "REOPENED" in possible_bug_statuses: 773 bug_status.value = ["REOPENED"] 774 # If the bug was never confirmed it will not have a "REOPENED" 775 # state, but only an "UNCONFIRMED" state. 776 elif "UNCONFIRMED" in possible_bug_statuses: 777 bug_status.value = ["UNCONFIRMED"] 778 else: 779 # FIXME: This logic is slightly backwards. We won't print this 780 # message if the bug is already open with state "UNCONFIRMED". 781 log("Did not reopen bug %s, it appears to already be open with status %s." % (bug_id, bug_status.value)) 782 self.browser['comment'] = comment_text 783 self.browser.submit() 784