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