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 re 34import subprocess 35 36from datetime import datetime # used in timestamp() 37 38# Import WebKit-specific modules. 39from webkitpy.webkit_logging import error, log 40from webkitpy.committers import CommitterList 41from webkitpy.credentials import Credentials 42from webkitpy.user import User 43 44# WebKit includes a built copy of BeautifulSoup in Scripts/webkitpy 45# so this import should always succeed. 46from .BeautifulSoup import BeautifulSoup, SoupStrainer 47 48from mechanize import Browser 49 50 51def parse_bug_id(message): 52 match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message) 53 if match: 54 return int(match.group('bug_id')) 55 match = re.search( 56 Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", 57 message) 58 if match: 59 return int(match.group('bug_id')) 60 return None 61 62 63def timestamp(): 64 return datetime.now().strftime("%Y%m%d%H%M%S") 65 66 67class Attachment(object): 68 69 def __init__(self, attachment_dictionary, bug): 70 self._attachment_dictionary = attachment_dictionary 71 self._bug = bug 72 self._reviewer = None 73 self._committer = None 74 75 def _bugzilla(self): 76 return self._bug._bugzilla 77 78 def id(self): 79 return int(self._attachment_dictionary.get("id")) 80 81 def attacher_is_committer(self): 82 return self._bugzilla.committers.committer_by_email( 83 patch.attacher_email()) 84 85 def attacher_email(self): 86 return self._attachment_dictionary.get("attacher_email") 87 88 def bug(self): 89 return self._bug 90 91 def bug_id(self): 92 return int(self._attachment_dictionary.get("bug_id")) 93 94 def is_patch(self): 95 return not not self._attachment_dictionary.get("is_patch") 96 97 def is_obsolete(self): 98 return not not self._attachment_dictionary.get("is_obsolete") 99 100 def name(self): 101 return self._attachment_dictionary.get("name") 102 103 def review(self): 104 return self._attachment_dictionary.get("review") 105 106 def commit_queue(self): 107 return self._attachment_dictionary.get("commit-queue") 108 109 def url(self): 110 # FIXME: This should just return 111 # self._bugzilla().attachment_url_for_id(self.id()). scm_unittest.py 112 # depends on the current behavior. 113 return self._attachment_dictionary.get("url") 114 115 def _validate_flag_value(self, flag): 116 email = self._attachment_dictionary.get("%s_email" % flag) 117 if not email: 118 return None 119 committer = getattr(self._bugzilla().committers, 120 "%s_by_email" % flag)(email) 121 if committer: 122 return committer 123 log("Warning, attachment %s on bug %s has invalid %s (%s)" % ( 124 self._attachment_dictionary['id'], 125 self._attachment_dictionary['bug_id'], flag, email)) 126 127 def reviewer(self): 128 if not self._reviewer: 129 self._reviewer = self._validate_flag_value("reviewer") 130 return self._reviewer 131 132 def committer(self): 133 if not self._committer: 134 self._committer = self._validate_flag_value("committer") 135 return self._committer 136 137 138class Bug(object): 139 # FIXME: This class is kinda a hack for now. It exists so we have one 140 # place to hold bug logic, even if much of the code deals with 141 # dictionaries still. 142 143 def __init__(self, bug_dictionary, bugzilla): 144 self.bug_dictionary = bug_dictionary 145 self._bugzilla = bugzilla 146 147 def id(self): 148 return self.bug_dictionary["id"] 149 150 def assigned_to_email(self): 151 return self.bug_dictionary["assigned_to_email"] 152 153 # Rarely do we actually want obsolete attachments 154 def attachments(self, include_obsolete=False): 155 attachments = self.bug_dictionary["attachments"] 156 if not include_obsolete: 157 attachments = filter(lambda attachment: 158 not attachment["is_obsolete"], attachments) 159 return [Attachment(attachment, self) for attachment in attachments] 160 161 def patches(self, include_obsolete=False): 162 return [patch for patch in self.attachments(include_obsolete) 163 if patch.is_patch()] 164 165 def unreviewed_patches(self): 166 return [patch for patch in self.patches() if patch.review() == "?"] 167 168 def reviewed_patches(self, include_invalid=False): 169 patches = [patch for patch in self.patches() if patch.review() == "+"] 170 if include_invalid: 171 return patches 172 # Checking reviewer() ensures that it was both reviewed and has a valid 173 # reviewer. 174 return filter(lambda patch: patch.reviewer(), patches) 175 176 def commit_queued_patches(self, include_invalid=False): 177 patches = [patch for patch in self.patches() 178 if patch.commit_queue() == "+"] 179 if include_invalid: 180 return patches 181 # Checking committer() ensures that it was both commit-queue+'d and has 182 # a valid committer. 183 return filter(lambda patch: patch.committer(), patches) 184 185 186# A container for all of the logic for making and parsing buzilla queries. 187class BugzillaQueries(object): 188 189 def __init__(self, bugzilla): 190 self._bugzilla = bugzilla 191 192 # Note: _load_query and _fetch_bug are the only two methods which access 193 # self._bugzilla. 194 195 def _load_query(self, query): 196 self._bugzilla.authenticate() 197 198 full_url = "%s%s" % (self._bugzilla.bug_server_url, query) 199 return self._bugzilla.browser.open(full_url) 200 201 def _fetch_bug(self, bug_id): 202 return self._bugzilla.fetch_bug(bug_id) 203 204 def _fetch_bug_ids_advanced_query(self, query): 205 soup = BeautifulSoup(self._load_query(query)) 206 # The contents of the <a> inside the cells in the first column happen 207 # to be the bug id. 208 return [int(bug_link_cell.find("a").string) 209 for bug_link_cell in soup('td', "first-child")] 210 211 def _parse_attachment_ids_request_query(self, page): 212 digits = re.compile("\d+") 213 attachment_href = re.compile("attachment.cgi\?id=\d+&action=review") 214 attachment_links = SoupStrainer("a", href=attachment_href) 215 return [int(digits.search(tag["href"]).group(0)) 216 for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)] 217 218 def _fetch_attachment_ids_request_query(self, query): 219 return self._parse_attachment_ids_request_query(self._load_query(query)) 220 221 # List of all r+'d bugs. 222 def fetch_bug_ids_from_pending_commit_list(self): 223 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" 224 return self._fetch_bug_ids_advanced_query(needs_commit_query_url) 225 226 def fetch_patches_from_pending_commit_list(self): 227 return sum([self._fetch_bug(bug_id).reviewed_patches() 228 for bug_id in self.fetch_bug_ids_from_pending_commit_list()], []) 229 230 def fetch_bug_ids_from_commit_queue(self): 231 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" 232 return self._fetch_bug_ids_advanced_query(commit_queue_url) 233 234 def fetch_patches_from_commit_queue(self): 235 # This function will only return patches which have valid committers 236 # set. It won't reject patches with invalid committers/reviewers. 237 return sum([self._fetch_bug(bug_id).commit_queued_patches() 238 for bug_id in self.fetch_bug_ids_from_commit_queue()], []) 239 240 def _fetch_bug_ids_from_review_queue(self): 241 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?" 242 return self._fetch_bug_ids_advanced_query(review_queue_url) 243 244 def fetch_patches_from_review_queue(self, limit=None): 245 # [:None] returns the whole array. 246 return sum([self._fetch_bug(bug_id).unreviewed_patches() 247 for bug_id in self._fetch_bug_ids_from_review_queue()[:limit]], []) 248 249 # FIXME: Why do we have both fetch_patches_from_review_queue and 250 # fetch_attachment_ids_from_review_queue?? 251 # NOTE: This is also the only client of _fetch_attachment_ids_request_query 252 253 def fetch_attachment_ids_from_review_queue(self): 254 review_queue_url = "request.cgi?action=queue&type=review&group=type" 255 return self._fetch_attachment_ids_request_query(review_queue_url) 256 257 258class CommitterValidator(object): 259 260 def __init__(self, bugzilla): 261 self._bugzilla = bugzilla 262 263 # _view_source_url belongs in some sort of webkit_config.py module. 264 def _view_source_url(self, local_path): 265 return "http://trac.webkit.org/browser/trunk/%s" % local_path 266 267 def _flag_permission_rejection_message(self, setter_email, flag_name): 268 # This could be computed from CommitterList.__file__ 269 committer_list = "WebKitTools/Scripts/webkitpy/committers.py" 270 # Should come from some webkit_config.py 271 contribution_guidlines = "http://webkit.org/coding/contributing.html" 272 # This could be queried from the status_server. 273 queue_administrator = "eseidel@chromium.org" 274 # This could be queried from the tool. 275 queue_name = "commit-queue" 276 message = "%s does not have %s permissions according to %s." % ( 277 setter_email, 278 flag_name, 279 self._view_source_url(committer_list)) 280 message += "\n\n- If you do not have %s rights please read %s for instructions on how to use bugzilla flags." % ( 281 flag_name, contribution_guidlines) 282 message += "\n\n- If you have %s rights please correct the error in %s by adding yourself to the file (no review needed). " % ( 283 flag_name, committer_list) 284 message += "Due to bug 30084 the %s will require a restart after your change. " % queue_name 285 message += "Please contact %s to request a %s restart. " % ( 286 queue_administrator, queue_name) 287 message += "After restart the %s will correctly respect your %s rights." % ( 288 queue_name, flag_name) 289 return message 290 291 def _validate_setter_email(self, patch, result_key, rejection_function): 292 committer = getattr(patch, result_key)() 293 # If the flag is set, and we don't recognize the setter, reject the 294 # flag! 295 setter_email = patch._attachment_dictionary.get("%s_email" % result_key) 296 if setter_email and not committer: 297 rejection_function(patch.id(), 298 self._flag_permission_rejection_message(setter_email, 299 result_key)) 300 return False 301 return True 302 303 def patches_after_rejecting_invalid_commiters_and_reviewers(self, patches): 304 validated_patches = [] 305 for patch in patches: 306 if (self._validate_setter_email( 307 patch, "reviewer", self.reject_patch_from_review_queue) 308 and self._validate_setter_email( 309 patch, "committer", self.reject_patch_from_commit_queue)): 310 validated_patches.append(patch) 311 return validated_patches 312 313 def reject_patch_from_commit_queue(self, 314 attachment_id, 315 additional_comment_text=None): 316 comment_text = "Rejecting patch %s from commit-queue." % attachment_id 317 self._bugzilla.set_flag_on_attachment(attachment_id, 318 "commit-queue", 319 "-", 320 comment_text, 321 additional_comment_text) 322 323 def reject_patch_from_review_queue(self, 324 attachment_id, 325 additional_comment_text=None): 326 comment_text = "Rejecting patch %s from review queue." % attachment_id 327 self._bugzilla.set_flag_on_attachment(attachment_id, 328 'review', 329 '-', 330 comment_text, 331 additional_comment_text) 332 333 334class Bugzilla(object): 335 336 def __init__(self, dryrun=False, committers=CommitterList()): 337 self.dryrun = dryrun 338 self.authenticated = False 339 self.queries = BugzillaQueries(self) 340 self.committers = committers 341 342 # FIXME: We should use some sort of Browser mock object when in dryrun 343 # mode (to prevent any mistakes). 344 self.browser = Browser() 345 # Ignore bugs.webkit.org/robots.txt until we fix it to allow this 346 # script. 347 self.browser.set_handle_robots(False) 348 349 # FIXME: Much of this should go into some sort of config module: 350 bug_server_host = "bugs.webkit.org" 351 bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host) 352 bug_server_url = "https://%s/" % bug_server_host 353 unassigned_email = "webkit-unassigned@lists.webkit.org" 354 355 def bug_url_for_bug_id(self, bug_id, xml=False): 356 content_type = "&ctype=xml" if xml else "" 357 return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url, 358 bug_id, 359 content_type) 360 361 def short_bug_url_for_bug_id(self, bug_id): 362 return "http://webkit.org/b/%s" % bug_id 363 364 def attachment_url_for_id(self, attachment_id, action="view"): 365 action_param = "" 366 if action and action != "view": 367 action_param = "&action=%s" % action 368 return "%sattachment.cgi?id=%s%s" % (self.bug_server_url, 369 attachment_id, 370 action_param) 371 372 def _parse_attachment_flag(self, 373 element, 374 flag_name, 375 attachment, 376 result_key): 377 flag = element.find('flag', attrs={'name': flag_name}) 378 if flag: 379 attachment[flag_name] = flag['status'] 380 if flag['status'] == '+': 381 attachment[result_key] = flag['setter'] 382 383 def _parse_attachment_element(self, element, bug_id): 384 attachment = {} 385 attachment['bug_id'] = bug_id 386 attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1") 387 attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1") 388 attachment['id'] = int(element.find('attachid').string) 389 # FIXME: No need to parse out the url here. 390 attachment['url'] = self.attachment_url_for_id(attachment['id']) 391 attachment['name'] = unicode(element.find('desc').string) 392 attachment['attacher_email'] = str(element.find('attacher').string) 393 attachment['type'] = str(element.find('type').string) 394 self._parse_attachment_flag( 395 element, 'review', attachment, 'reviewer_email') 396 self._parse_attachment_flag( 397 element, 'commit-queue', attachment, 'committer_email') 398 return attachment 399 400 def _parse_bug_page(self, page): 401 soup = BeautifulSoup(page) 402 bug = {} 403 bug["id"] = int(soup.find("bug_id").string) 404 bug["title"] = unicode(soup.find("short_desc").string) 405 bug["reporter_email"] = str(soup.find("reporter").string) 406 bug["assigned_to_email"] = str(soup.find("assigned_to").string) 407 bug["cc_emails"] = [str(element.string) 408 for element in soup.findAll('cc')] 409 bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')] 410 return bug 411 412 # Makes testing fetch_*_from_bug() possible until we have a better 413 # BugzillaNetwork abstration. 414 415 def _fetch_bug_page(self, bug_id): 416 bug_url = self.bug_url_for_bug_id(bug_id, xml=True) 417 log("Fetching: %s" % bug_url) 418 return self.browser.open(bug_url) 419 420 def fetch_bug_dictionary(self, bug_id): 421 return self._parse_bug_page(self._fetch_bug_page(bug_id)) 422 423 # FIXME: A BugzillaCache object should provide all these fetch_ methods. 424 425 def fetch_bug(self, bug_id): 426 return Bug(self.fetch_bug_dictionary(bug_id), self) 427 428 def _parse_bug_id_from_attachment_page(self, page): 429 # The "Up" relation happens to point to the bug. 430 up_link = BeautifulSoup(page).find('link', rel='Up') 431 if not up_link: 432 # This attachment does not exist (or you don't have permissions to 433 # view it). 434 return None 435 match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href']) 436 return int(match.group('bug_id')) 437 438 def bug_id_for_attachment_id(self, attachment_id): 439 self.authenticate() 440 441 attachment_url = self.attachment_url_for_id(attachment_id, 'edit') 442 log("Fetching: %s" % attachment_url) 443 page = self.browser.open(attachment_url) 444 return self._parse_bug_id_from_attachment_page(page) 445 446 # FIXME: This should just return Attachment(id), which should be able to 447 # lazily fetch needed data. 448 449 def fetch_attachment(self, attachment_id): 450 # We could grab all the attachment details off of the attachment edit 451 # page but we already have working code to do so off of the bugs page, 452 # so re-use that. 453 bug_id = self.bug_id_for_attachment_id(attachment_id) 454 if not bug_id: 455 return None 456 attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True) 457 for attachment in attachments: 458 if attachment.id() == int(attachment_id): 459 return attachment 460 return None # This should never be hit. 461 462 def authenticate(self): 463 if self.authenticated: 464 return 465 466 if self.dryrun: 467 log("Skipping log in for dry run...") 468 self.authenticated = True 469 return 470 471 attempts = 0 472 while not self.authenticated: 473 attempts += 1 474 (username, password) = Credentials( 475 self.bug_server_host, git_prefix="bugzilla").read_credentials() 476 477 log("Logging in as %s..." % username) 478 self.browser.open(self.bug_server_url + 479 "index.cgi?GoAheadAndLogIn=1") 480 self.browser.select_form(name="login") 481 self.browser['Bugzilla_login'] = username 482 self.browser['Bugzilla_password'] = password 483 response = self.browser.submit() 484 485 match = re.search("<title>(.+?)</title>", response.read()) 486 # If the resulting page has a title, and it contains the word 487 # "invalid" assume it's the login failure page. 488 if match and re.search("Invalid", match.group(1), re.IGNORECASE): 489 errorMessage = "Bugzilla login failed: %s" % match.group(1) 490 # raise an exception only if this was the last attempt 491 if attempts < 5: 492 log(errorMessage) 493 else: 494 raise Exception(errorMessage) 495 else: 496 self.authenticated = True 497 498 def _fill_attachment_form(self, 499 description, 500 patch_file_object, 501 comment_text=None, 502 mark_for_review=False, 503 mark_for_commit_queue=False, 504 mark_for_landing=False, bug_id=None): 505 self.browser['description'] = description 506 self.browser['ispatch'] = ("1",) 507 self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',) 508 509 if mark_for_landing: 510 self.browser['flag_type-3'] = ('+',) 511 elif mark_for_commit_queue: 512 self.browser['flag_type-3'] = ('?',) 513 else: 514 self.browser['flag_type-3'] = ('X',) 515 516 if bug_id: 517 patch_name = "bug-%s-%s.patch" % (bug_id, timestamp()) 518 else: 519 patch_name ="%s.patch" % timestamp() 520 self.browser.add_file(patch_file_object, 521 "text/plain", 522 patch_name, 523 'data') 524 525 def add_patch_to_bug(self, 526 bug_id, 527 patch_file_object, 528 description, 529 comment_text=None, 530 mark_for_review=False, 531 mark_for_commit_queue=False, 532 mark_for_landing=False): 533 self.authenticate() 534 535 log('Adding patch "%s" to %sshow_bug.cgi?id=%s' % (description, 536 self.bug_server_url, 537 bug_id)) 538 539 if self.dryrun: 540 log(comment_text) 541 return 542 543 self.browser.open("%sattachment.cgi?action=enter&bugid=%s" % ( 544 self.bug_server_url, bug_id)) 545 self.browser.select_form(name="entryform") 546 self._fill_attachment_form(description, 547 patch_file_object, 548 mark_for_review=mark_for_review, 549 mark_for_commit_queue=mark_for_commit_queue, 550 mark_for_landing=mark_for_landing, 551 bug_id=bug_id) 552 if comment_text: 553 log(comment_text) 554 self.browser['comment'] = comment_text 555 self.browser.submit() 556 557 def prompt_for_component(self, components): 558 log("Please pick a component:") 559 i = 0 560 for name in components: 561 i += 1 562 log("%2d. %s" % (i, name)) 563 result = int(User.prompt("Enter a number: ")) - 1 564 return components[result] 565 566 def _check_create_bug_response(self, response_html): 567 match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>", 568 response_html) 569 if match: 570 return match.group('bug_id') 571 572 match = re.search( 573 '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">', 574 response_html, 575 re.DOTALL) 576 error_message = "FAIL" 577 if match: 578 text_lines = BeautifulSoup( 579 match.group('error_message')).findAll(text=True) 580 error_message = "\n" + '\n'.join( 581 [" " + line.strip() 582 for line in text_lines if line.strip()]) 583 raise Exception("Bug not created: %s" % error_message) 584 585 def create_bug(self, 586 bug_title, 587 bug_description, 588 component=None, 589 patch_file_object=None, 590 patch_description=None, 591 cc=None, 592 mark_for_review=False, 593 mark_for_commit_queue=False): 594 self.authenticate() 595 596 log('Creating bug with title "%s"' % bug_title) 597 if self.dryrun: 598 log(bug_description) 599 return 600 601 self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit") 602 self.browser.select_form(name="Create") 603 component_items = self.browser.find_control('component').items 604 component_names = map(lambda item: item.name, component_items) 605 if not component: 606 component = "New Bugs" 607 if component not in component_names: 608 component = self.prompt_for_component(component_names) 609 self.browser['component'] = [component] 610 if cc: 611 self.browser['cc'] = cc 612 self.browser['short_desc'] = bug_title 613 self.browser['comment'] = bug_description 614 615 if patch_file_object: 616 self._fill_attachment_form( 617 patch_description, 618 patch_file_object, 619 mark_for_review=mark_for_review, 620 mark_for_commit_queue=mark_for_commit_queue) 621 622 response = self.browser.submit() 623 624 bug_id = self._check_create_bug_response(response.read()) 625 log("Bug %s created." % bug_id) 626 log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id)) 627 return bug_id 628 629 def _find_select_element_for_flag(self, flag_name): 630 # FIXME: This will break if we ever re-order attachment flags 631 if flag_name == "review": 632 return self.browser.find_control(type='select', nr=0) 633 if flag_name == "commit-queue": 634 return self.browser.find_control(type='select', nr=1) 635 raise Exception("Don't know how to find flag named \"%s\"" % flag_name) 636 637 def clear_attachment_flags(self, 638 attachment_id, 639 additional_comment_text=None): 640 self.authenticate() 641 642 comment_text = "Clearing flags on attachment: %s" % attachment_id 643 if additional_comment_text: 644 comment_text += "\n\n%s" % additional_comment_text 645 log(comment_text) 646 647 if self.dryrun: 648 return 649 650 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) 651 self.browser.select_form(nr=1) 652 self.browser.set_value(comment_text, name='comment', nr=0) 653 self._find_select_element_for_flag('review').value = ("X",) 654 self._find_select_element_for_flag('commit-queue').value = ("X",) 655 self.browser.submit() 656 657 def set_flag_on_attachment(self, 658 attachment_id, 659 flag_name, 660 flag_value, 661 comment_text, 662 additional_comment_text): 663 # FIXME: We need a way to test this function on a live bugzilla 664 # instance. 665 666 self.authenticate() 667 668 if additional_comment_text: 669 comment_text += "\n\n%s" % additional_comment_text 670 log(comment_text) 671 672 if self.dryrun: 673 return 674 675 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) 676 self.browser.select_form(nr=1) 677 self.browser.set_value(comment_text, name='comment', nr=0) 678 self._find_select_element_for_flag(flag_name).value = (flag_value,) 679 self.browser.submit() 680 681 # FIXME: All of these bug editing methods have a ridiculous amount of 682 # copy/paste code. 683 684 def obsolete_attachment(self, attachment_id, comment_text=None): 685 self.authenticate() 686 687 log("Obsoleting attachment: %s" % attachment_id) 688 if self.dryrun: 689 log(comment_text) 690 return 691 692 self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) 693 self.browser.select_form(nr=1) 694 self.browser.find_control('isobsolete').items[0].selected = True 695 # Also clear any review flag (to remove it from review/commit queues) 696 self._find_select_element_for_flag('review').value = ("X",) 697 self._find_select_element_for_flag('commit-queue').value = ("X",) 698 if comment_text: 699 log(comment_text) 700 # Bugzilla has two textareas named 'comment', one is somehow 701 # hidden. We want the first. 702 self.browser.set_value(comment_text, name='comment', nr=0) 703 self.browser.submit() 704 705 def add_cc_to_bug(self, bug_id, email_address_list): 706 self.authenticate() 707 708 log("Adding %s to the CC list for bug %s" % (email_address_list, 709 bug_id)) 710 if self.dryrun: 711 return 712 713 self.browser.open(self.bug_url_for_bug_id(bug_id)) 714 self.browser.select_form(name="changeform") 715 self.browser["newcc"] = ", ".join(email_address_list) 716 self.browser.submit() 717 718 def post_comment_to_bug(self, bug_id, comment_text, cc=None): 719 self.authenticate() 720 721 log("Adding comment to bug %s" % bug_id) 722 if self.dryrun: 723 log(comment_text) 724 return 725 726 self.browser.open(self.bug_url_for_bug_id(bug_id)) 727 self.browser.select_form(name="changeform") 728 self.browser["comment"] = comment_text 729 if cc: 730 self.browser["newcc"] = ", ".join(cc) 731 self.browser.submit() 732 733 def close_bug_as_fixed(self, bug_id, comment_text=None): 734 self.authenticate() 735 736 log("Closing bug %s as fixed" % bug_id) 737 if self.dryrun: 738 log(comment_text) 739 return 740 741 self.browser.open(self.bug_url_for_bug_id(bug_id)) 742 self.browser.select_form(name="changeform") 743 if comment_text: 744 log(comment_text) 745 self.browser['comment'] = comment_text 746 self.browser['bug_status'] = ['RESOLVED'] 747 self.browser['resolution'] = ['FIXED'] 748 self.browser.submit() 749 750 def reassign_bug(self, bug_id, assignee, comment_text=None): 751 self.authenticate() 752 753 log("Assigning bug %s to %s" % (bug_id, assignee)) 754 if self.dryrun: 755 log(comment_text) 756 return 757 758 self.browser.open(self.bug_url_for_bug_id(bug_id)) 759 self.browser.select_form(name="changeform") 760 if comment_text: 761 log(comment_text) 762 self.browser["comment"] = comment_text 763 self.browser["assigned_to"] = assignee 764 self.browser.submit() 765 766 def reopen_bug(self, bug_id, comment_text): 767 self.authenticate() 768 769 log("Re-opening bug %s" % bug_id) 770 # Bugzilla requires a comment when re-opening a bug, so we know it will 771 # never be None. 772 log(comment_text) 773 if self.dryrun: 774 return 775 776 self.browser.open(self.bug_url_for_bug_id(bug_id)) 777 self.browser.select_form(name="changeform") 778 bug_status = self.browser.find_control("bug_status", type="select") 779 # This is a hack around the fact that ClientForm.ListControl seems to 780 # have no simpler way to ask if a control has an item named "REOPENED" 781 # without using exceptions for control flow. 782 possible_bug_statuses = map(lambda item: item.name, bug_status.items) 783 if "REOPENED" in possible_bug_statuses: 784 bug_status.value = ["REOPENED"] 785 else: 786 log("Did not reopen bug %s. " + 787 "It appears to already be open with status %s." % ( 788 bug_id, bug_status.value)) 789 self.browser['comment'] = comment_text 790 self.browser.submit() 791