Home | History | Annotate | Download | only in webkitpy
      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 re
     34 import subprocess
     35 
     36 from datetime import datetime # used in timestamp()
     37 
     38 # Import WebKit-specific modules.
     39 from webkitpy.webkit_logging import error, log
     40 from webkitpy.committers import CommitterList
     41 from webkitpy.credentials import Credentials
     42 from webkitpy.user import User
     43 
     44 # WebKit includes a built copy of BeautifulSoup in Scripts/webkitpy
     45 # so this import should always succeed.
     46 from .BeautifulSoup import BeautifulSoup, SoupStrainer
     47 
     48 from mechanize import Browser
     49 
     50 
     51 def 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 
     63 def timestamp():
     64     return datetime.now().strftime("%Y%m%d%H%M%S")
     65 
     66 
     67 class 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 
    138 class 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.
    187 class 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 
    258 class 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 (at] 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 
    334 class 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 (at] 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