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 33import mimetypes 34import os.path 35import re 36import StringIO 37import urllib 38 39from datetime import datetime # used in timestamp() 40 41from .attachment import Attachment 42from .bug import Bug 43 44from webkitpy.common.system.deprecated_logging import log 45from webkitpy.common.config import committers 46from webkitpy.common.net.credentials import Credentials 47from webkitpy.common.system.user import User 48from webkitpy.thirdparty.autoinstalled.mechanize import Browser 49from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, BeautifulStoneSoup, SoupStrainer 50 51 52# FIXME: parse_bug_id should not be a free function. 53def 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 68def 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 81def 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. 86class 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 207class 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