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