summaryrefslogtreecommitdiffstats
path: root/Tools/Scripts/webkitpy/common/net/bugzilla
diff options
context:
space:
mode:
Diffstat (limited to 'Tools/Scripts/webkitpy/common/net/bugzilla')
-rw-r--r--Tools/Scripts/webkitpy/common/net/bugzilla/__init__.py8
-rw-r--r--Tools/Scripts/webkitpy/common/net/bugzilla/attachment.py114
-rw-r--r--Tools/Scripts/webkitpy/common/net/bugzilla/bug.py111
-rw-r--r--Tools/Scripts/webkitpy/common/net/bugzilla/bug_unittest.py40
-rw-r--r--Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py761
-rw-r--r--Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla_unittest.py392
6 files changed, 1426 insertions, 0 deletions
diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/__init__.py b/Tools/Scripts/webkitpy/common/net/bugzilla/__init__.py
new file mode 100644
index 0000000..cfaf3b1
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/bugzilla/__init__.py
@@ -0,0 +1,8 @@
+# Required for Python to search this directory for module files
+
+# We only export public API here.
+# FIXME: parse_bug_id should not be a free function.
+from .bugzilla import Bugzilla, parse_bug_id
+# Unclear if Bug and Attachment need to be public classes.
+from .bug import Bug
+from .attachment import Attachment
diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/attachment.py b/Tools/Scripts/webkitpy/common/net/bugzilla/attachment.py
new file mode 100644
index 0000000..85761fe
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/bugzilla/attachment.py
@@ -0,0 +1,114 @@
+# Copyright (c) 2009 Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+# Copyright (c) 2010 Research In Motion Limited. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from webkitpy.common.system.deprecated_logging import log
+
+
+class Attachment(object):
+
+ rollout_preamble = "ROLLOUT of r"
+
+ def __init__(self, attachment_dictionary, bug):
+ self._attachment_dictionary = attachment_dictionary
+ self._bug = bug
+ self._reviewer = None
+ self._committer = None
+
+ def _bugzilla(self):
+ return self._bug._bugzilla
+
+ def id(self):
+ return int(self._attachment_dictionary.get("id"))
+
+ def attacher_is_committer(self):
+ return self._bugzilla.committers.committer_by_email(
+ patch.attacher_email())
+
+ def attacher_email(self):
+ return self._attachment_dictionary.get("attacher_email")
+
+ def bug(self):
+ return self._bug
+
+ def bug_id(self):
+ return int(self._attachment_dictionary.get("bug_id"))
+
+ def is_patch(self):
+ return not not self._attachment_dictionary.get("is_patch")
+
+ def is_obsolete(self):
+ return not not self._attachment_dictionary.get("is_obsolete")
+
+ def is_rollout(self):
+ return self.name().startswith(self.rollout_preamble)
+
+ def name(self):
+ return self._attachment_dictionary.get("name")
+
+ def attach_date(self):
+ return self._attachment_dictionary.get("attach_date")
+
+ def review(self):
+ return self._attachment_dictionary.get("review")
+
+ def commit_queue(self):
+ return self._attachment_dictionary.get("commit-queue")
+
+ def url(self):
+ # FIXME: This should just return
+ # self._bugzilla().attachment_url_for_id(self.id()). scm_unittest.py
+ # depends on the current behavior.
+ return self._attachment_dictionary.get("url")
+
+ def contents(self):
+ # FIXME: We shouldn't be grabbing at _bugzilla.
+ return self._bug._bugzilla.fetch_attachment_contents(self.id())
+
+ def _validate_flag_value(self, flag):
+ email = self._attachment_dictionary.get("%s_email" % flag)
+ if not email:
+ return None
+ committer = getattr(self._bugzilla().committers,
+ "%s_by_email" % flag)(email)
+ if committer:
+ return committer
+ log("Warning, attachment %s on bug %s has invalid %s (%s)" % (
+ self._attachment_dictionary['id'],
+ self._attachment_dictionary['bug_id'], flag, email))
+
+ def reviewer(self):
+ if not self._reviewer:
+ self._reviewer = self._validate_flag_value("reviewer")
+ return self._reviewer
+
+ def committer(self):
+ if not self._committer:
+ self._committer = self._validate_flag_value("committer")
+ return self._committer
diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bug.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bug.py
new file mode 100644
index 0000000..af258eb
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bug.py
@@ -0,0 +1,111 @@
+# Copyright (c) 2009 Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+# Copyright (c) 2010 Research In Motion Limited. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from .attachment import Attachment
+
+
+class Bug(object):
+ # FIXME: This class is kinda a hack for now. It exists so we have one
+ # place to hold bug logic, even if much of the code deals with
+ # dictionaries still.
+
+ def __init__(self, bug_dictionary, bugzilla):
+ self.bug_dictionary = bug_dictionary
+ self._bugzilla = bugzilla
+
+ def id(self):
+ return self.bug_dictionary["id"]
+
+ def title(self):
+ return self.bug_dictionary["title"]
+
+ def reporter_email(self):
+ return self.bug_dictionary["reporter_email"]
+
+ def assigned_to_email(self):
+ return self.bug_dictionary["assigned_to_email"]
+
+ # FIXME: This information should be stored in some sort of webkit_config.py instead of here.
+ unassigned_emails = frozenset([
+ "webkit-unassigned@lists.webkit.org",
+ "webkit-qt-unassigned@trolltech.com",
+ ])
+
+ def is_unassigned(self):
+ return self.assigned_to_email() in self.unassigned_emails
+
+ def status(self):
+ return self.bug_dictionary["bug_status"]
+
+ # Bugzilla has many status states we don't really use in WebKit:
+ # https://bugs.webkit.org/page.cgi?id=fields.html#status
+ _open_states = ["UNCONFIRMED", "NEW", "ASSIGNED", "REOPENED"]
+ _closed_states = ["RESOLVED", "VERIFIED", "CLOSED"]
+
+ def is_open(self):
+ return self.status() in self._open_states
+
+ def is_closed(self):
+ return not self.is_open()
+
+ def duplicate_of(self):
+ return self.bug_dictionary.get('dup_id', None)
+
+ # Rarely do we actually want obsolete attachments
+ def attachments(self, include_obsolete=False):
+ attachments = self.bug_dictionary["attachments"]
+ if not include_obsolete:
+ attachments = filter(lambda attachment:
+ not attachment["is_obsolete"], attachments)
+ return [Attachment(attachment, self) for attachment in attachments]
+
+ def patches(self, include_obsolete=False):
+ return [patch for patch in self.attachments(include_obsolete)
+ if patch.is_patch()]
+
+ def unreviewed_patches(self):
+ return [patch for patch in self.patches() if patch.review() == "?"]
+
+ def reviewed_patches(self, include_invalid=False):
+ patches = [patch for patch in self.patches() if patch.review() == "+"]
+ if include_invalid:
+ return patches
+ # Checking reviewer() ensures that it was both reviewed and has a valid
+ # reviewer.
+ return filter(lambda patch: patch.reviewer(), patches)
+
+ def commit_queued_patches(self, include_invalid=False):
+ patches = [patch for patch in self.patches()
+ if patch.commit_queue() == "+"]
+ if include_invalid:
+ return patches
+ # Checking committer() ensures that it was both commit-queue+'d and has
+ # a valid committer.
+ return filter(lambda patch: patch.committer(), patches)
diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bug_unittest.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bug_unittest.py
new file mode 100644
index 0000000..d43d64f
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bug_unittest.py
@@ -0,0 +1,40 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from .bug import Bug
+
+
+class BugTest(unittest.TestCase):
+ def test_is_unassigned(self):
+ for email in Bug.unassigned_emails:
+ bug = Bug({"assigned_to_email": email}, bugzilla=None)
+ self.assertTrue(bug.is_unassigned())
+ bug = Bug({"assigned_to_email": "test@test.com"}, bugzilla=None)
+ self.assertFalse(bug.is_unassigned())
diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py
new file mode 100644
index 0000000..d6210d5
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py
@@ -0,0 +1,761 @@
+# Copyright (c) 2009 Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+# Copyright (c) 2010 Research In Motion Limited. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for interacting with Bugzilla
+
+import os.path
+import re
+import StringIO
+import urllib
+
+from datetime import datetime # used in timestamp()
+
+from .attachment import Attachment
+from .bug import Bug
+
+from webkitpy.common.system.deprecated_logging import log
+from webkitpy.common.config import committers
+from webkitpy.common.net.credentials import Credentials
+from webkitpy.common.system.user import User
+from webkitpy.thirdparty.autoinstalled.mechanize import Browser
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, SoupStrainer
+
+
+# FIXME: parse_bug_id should not be a free function.
+def parse_bug_id(message):
+ if not message:
+ return None
+ match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message)
+ if match:
+ return int(match.group('bug_id'))
+ match = re.search(
+ Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)",
+ message)
+ if match:
+ return int(match.group('bug_id'))
+ return None
+
+
+def timestamp():
+ return datetime.now().strftime("%Y%m%d%H%M%S")
+
+
+# A container for all of the logic for making and parsing buzilla queries.
+class BugzillaQueries(object):
+
+ def __init__(self, bugzilla):
+ self._bugzilla = bugzilla
+
+ def _is_xml_bugs_form(self, form):
+ # ClientForm.HTMLForm.find_control throws if the control is not found,
+ # so we do a manual search instead:
+ return "xml" in [control.id for control in form.controls]
+
+ # This is kinda a hack. There is probably a better way to get this information from bugzilla.
+ def _parse_result_count(self, results_page):
+ result_count_text = BeautifulSoup(results_page).find(attrs={'class': 'bz_result_count'}).string
+ result_count_parts = result_count_text.strip().split(" ")
+ if result_count_parts[0] == "Zarro":
+ return 0
+ if result_count_parts[0] == "One":
+ return 1
+ return int(result_count_parts[0])
+
+ # Note: _load_query, _fetch_bug and _fetch_bugs_from_advanced_query
+ # are the only methods which access self._bugzilla.
+
+ def _load_query(self, query):
+ self._bugzilla.authenticate()
+ full_url = "%s%s" % (self._bugzilla.bug_server_url, query)
+ return self._bugzilla.browser.open(full_url)
+
+ def _fetch_bugs_from_advanced_query(self, query):
+ results_page = self._load_query(query)
+ if not self._parse_result_count(results_page):
+ return []
+ # Bugzilla results pages have an "XML" submit button at the bottom
+ # which can be used to get an XML page containing all of the <bug> elements.
+ # This is slighty lame that this assumes that _load_query used
+ # self._bugzilla.browser and that it's in an acceptable state.
+ self._bugzilla.browser.select_form(predicate=self._is_xml_bugs_form)
+ bugs_xml = self._bugzilla.browser.submit()
+ return self._bugzilla._parse_bugs_from_xml(bugs_xml)
+
+ def _fetch_bug(self, bug_id):
+ return self._bugzilla.fetch_bug(bug_id)
+
+ def _fetch_bug_ids_advanced_query(self, query):
+ soup = BeautifulSoup(self._load_query(query))
+ # The contents of the <a> inside the cells in the first column happen
+ # to be the bug id.
+ return [int(bug_link_cell.find("a").string)
+ for bug_link_cell in soup('td', "first-child")]
+
+ def _parse_attachment_ids_request_query(self, page):
+ digits = re.compile("\d+")
+ attachment_href = re.compile("attachment.cgi\?id=\d+&action=review")
+ attachment_links = SoupStrainer("a", href=attachment_href)
+ return [int(digits.search(tag["href"]).group(0))
+ for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)]
+
+ def _fetch_attachment_ids_request_query(self, query):
+ return self._parse_attachment_ids_request_query(self._load_query(query))
+
+ def _parse_quips(self, page):
+ soup = BeautifulSoup(page, convertEntities=BeautifulSoup.HTML_ENTITIES)
+ quips = soup.find(text=re.compile(r"Existing quips:")).findNext("ul").findAll("li")
+ return [unicode(quip_entry.string) for quip_entry in quips]
+
+ def fetch_quips(self):
+ return self._parse_quips(self._load_query("/quips.cgi?action=show"))
+
+ # List of all r+'d bugs.
+ def fetch_bug_ids_from_pending_commit_list(self):
+ 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"
+ return self._fetch_bug_ids_advanced_query(needs_commit_query_url)
+
+ def fetch_bugs_matching_quicksearch(self, search_string):
+ # We may want to use a more explicit query than "quicksearch".
+ # If quicksearch changes we should probably change to use
+ # a normal buglist.cgi?query_format=advanced query.
+ quicksearch_url = "buglist.cgi?quicksearch=%s" % urllib.quote(search_string)
+ return self._fetch_bugs_from_advanced_query(quicksearch_url)
+
+ # Currently this returns all bugs across all components.
+ # In the future we may wish to extend this API to construct more restricted searches.
+ def fetch_bugs_matching_search(self, search_string, author_email=None):
+ query = "buglist.cgi?query_format=advanced"
+ if search_string:
+ query += "&short_desc_type=allwordssubstr&short_desc=%s" % urllib.quote(search_string)
+ if author_email:
+ query += "&emailreporter1=1&emailtype1=substring&email1=%s" % urllib.quote(search_string)
+ return self._fetch_bugs_from_advanced_query(query)
+
+ def fetch_patches_from_pending_commit_list(self):
+ return sum([self._fetch_bug(bug_id).reviewed_patches()
+ for bug_id in self.fetch_bug_ids_from_pending_commit_list()], [])
+
+ def fetch_bug_ids_from_commit_queue(self):
+ 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"
+ return self._fetch_bug_ids_advanced_query(commit_queue_url)
+
+ def fetch_patches_from_commit_queue(self):
+ # This function will only return patches which have valid committers
+ # set. It won't reject patches with invalid committers/reviewers.
+ return sum([self._fetch_bug(bug_id).commit_queued_patches()
+ for bug_id in self.fetch_bug_ids_from_commit_queue()], [])
+
+ def fetch_bug_ids_from_review_queue(self):
+ 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?"
+ return self._fetch_bug_ids_advanced_query(review_queue_url)
+
+ # This method will make several requests to bugzilla.
+ def fetch_patches_from_review_queue(self, limit=None):
+ # [:None] returns the whole array.
+ return sum([self._fetch_bug(bug_id).unreviewed_patches()
+ for bug_id in self.fetch_bug_ids_from_review_queue()[:limit]], [])
+
+ # NOTE: This is the only client of _fetch_attachment_ids_request_query
+ # This method only makes one request to bugzilla.
+ def fetch_attachment_ids_from_review_queue(self):
+ review_queue_url = "request.cgi?action=queue&type=review&group=type"
+ return self._fetch_attachment_ids_request_query(review_queue_url)
+
+
+class Bugzilla(object):
+
+ def __init__(self, dryrun=False, committers=committers.CommitterList()):
+ self.dryrun = dryrun
+ self.authenticated = False
+ self.queries = BugzillaQueries(self)
+ self.committers = committers
+ self.cached_quips = []
+
+ # FIXME: We should use some sort of Browser mock object when in dryrun
+ # mode (to prevent any mistakes).
+ self.browser = Browser()
+ # Ignore bugs.webkit.org/robots.txt until we fix it to allow this
+ # script.
+ self.browser.set_handle_robots(False)
+
+ # FIXME: Much of this should go into some sort of config module:
+ bug_server_host = "bugs.webkit.org"
+ bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host)
+ bug_server_url = "https://%s/" % bug_server_host
+
+ def quips(self):
+ # We only fetch and parse the list of quips once per instantiation
+ # so that we do not burden bugs.webkit.org.
+ if not self.cached_quips and not self.dryrun:
+ self.cached_quips = self.queries.fetch_quips()
+ return self.cached_quips
+
+ def bug_url_for_bug_id(self, bug_id, xml=False):
+ if not bug_id:
+ return None
+ content_type = "&ctype=xml" if xml else ""
+ return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url, bug_id, content_type)
+
+ def short_bug_url_for_bug_id(self, bug_id):
+ if not bug_id:
+ return None
+ return "http://webkit.org/b/%s" % bug_id
+
+ def add_attachment_url(self, bug_id):
+ return "%sattachment.cgi?action=enter&bugid=%s" % (self.bug_server_url, bug_id)
+
+ def attachment_url_for_id(self, attachment_id, action="view"):
+ if not attachment_id:
+ return None
+ action_param = ""
+ if action and action != "view":
+ action_param = "&action=%s" % action
+ return "%sattachment.cgi?id=%s%s" % (self.bug_server_url,
+ attachment_id,
+ action_param)
+
+ def _parse_attachment_flag(self,
+ element,
+ flag_name,
+ attachment,
+ result_key):
+ flag = element.find('flag', attrs={'name': flag_name})
+ if flag:
+ attachment[flag_name] = flag['status']
+ if flag['status'] == '+':
+ attachment[result_key] = flag['setter']
+ # Sadly show_bug.cgi?ctype=xml does not expose the flag modification date.
+
+ def _string_contents(self, soup):
+ # WebKit's bugzilla instance uses UTF-8.
+ # BeautifulSoup always returns Unicode strings, however
+ # the .string method returns a (unicode) NavigableString.
+ # NavigableString can confuse other parts of the code, so we
+ # convert from NavigableString to a real unicode() object using unicode().
+ return unicode(soup.string)
+
+ # Example: 2010-01-20 14:31 PST
+ # FIXME: Some bugzilla dates seem to have seconds in them?
+ # Python does not support timezones out of the box.
+ # Assume that bugzilla always uses PST (which is true for bugs.webkit.org)
+ _bugzilla_date_format = "%Y-%m-%d %H:%M"
+
+ @classmethod
+ def _parse_date(cls, date_string):
+ (date, time, time_zone) = date_string.split(" ")
+ # Ignore the timezone because python doesn't understand timezones out of the box.
+ date_string = "%s %s" % (date, time)
+ return datetime.strptime(date_string, cls._bugzilla_date_format)
+
+ def _date_contents(self, soup):
+ return self._parse_date(self._string_contents(soup))
+
+ def _parse_attachment_element(self, element, bug_id):
+ attachment = {}
+ attachment['bug_id'] = bug_id
+ attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1")
+ attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1")
+ attachment['id'] = int(element.find('attachid').string)
+ # FIXME: No need to parse out the url here.
+ attachment['url'] = self.attachment_url_for_id(attachment['id'])
+ attachment["attach_date"] = self._date_contents(element.find("date"))
+ attachment['name'] = self._string_contents(element.find('desc'))
+ attachment['attacher_email'] = self._string_contents(element.find('attacher'))
+ attachment['type'] = self._string_contents(element.find('type'))
+ self._parse_attachment_flag(
+ element, 'review', attachment, 'reviewer_email')
+ self._parse_attachment_flag(
+ element, 'commit-queue', attachment, 'committer_email')
+ return attachment
+
+ def _parse_bugs_from_xml(self, page):
+ soup = BeautifulSoup(page)
+ # Without the unicode() call, BeautifulSoup occasionally complains of being
+ # passed None for no apparent reason.
+ return [Bug(self._parse_bug_dictionary_from_xml(unicode(bug_xml)), self) for bug_xml in soup('bug')]
+
+ def _parse_bug_dictionary_from_xml(self, page):
+ soup = BeautifulSoup(page)
+ bug = {}
+ bug["id"] = int(soup.find("bug_id").string)
+ bug["title"] = self._string_contents(soup.find("short_desc"))
+ bug["bug_status"] = self._string_contents(soup.find("bug_status"))
+ dup_id = soup.find("dup_id")
+ if dup_id:
+ bug["dup_id"] = self._string_contents(dup_id)
+ bug["reporter_email"] = self._string_contents(soup.find("reporter"))
+ bug["assigned_to_email"] = self._string_contents(soup.find("assigned_to"))
+ bug["cc_emails"] = [self._string_contents(element) for element in soup.findAll('cc')]
+ bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')]
+ return bug
+
+ # Makes testing fetch_*_from_bug() possible until we have a better
+ # BugzillaNetwork abstration.
+
+ def _fetch_bug_page(self, bug_id):
+ bug_url = self.bug_url_for_bug_id(bug_id, xml=True)
+ log("Fetching: %s" % bug_url)
+ return self.browser.open(bug_url)
+
+ def fetch_bug_dictionary(self, bug_id):
+ try:
+ return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id))
+ except KeyboardInterrupt:
+ raise
+ except:
+ self.authenticate()
+ return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id))
+
+ # FIXME: A BugzillaCache object should provide all these fetch_ methods.
+
+ def fetch_bug(self, bug_id):
+ return Bug(self.fetch_bug_dictionary(bug_id), self)
+
+ def fetch_attachment_contents(self, attachment_id):
+ attachment_url = self.attachment_url_for_id(attachment_id)
+ # We need to authenticate to download patches from security bugs.
+ self.authenticate()
+ return self.browser.open(attachment_url).read()
+
+ def _parse_bug_id_from_attachment_page(self, page):
+ # The "Up" relation happens to point to the bug.
+ up_link = BeautifulSoup(page).find('link', rel='Up')
+ if not up_link:
+ # This attachment does not exist (or you don't have permissions to
+ # view it).
+ return None
+ match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href'])
+ return int(match.group('bug_id'))
+
+ def bug_id_for_attachment_id(self, attachment_id):
+ self.authenticate()
+
+ attachment_url = self.attachment_url_for_id(attachment_id, 'edit')
+ log("Fetching: %s" % attachment_url)
+ page = self.browser.open(attachment_url)
+ return self._parse_bug_id_from_attachment_page(page)
+
+ # FIXME: This should just return Attachment(id), which should be able to
+ # lazily fetch needed data.
+
+ def fetch_attachment(self, attachment_id):
+ # We could grab all the attachment details off of the attachment edit
+ # page but we already have working code to do so off of the bugs page,
+ # so re-use that.
+ bug_id = self.bug_id_for_attachment_id(attachment_id)
+ if not bug_id:
+ return None
+ attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True)
+ for attachment in attachments:
+ if attachment.id() == int(attachment_id):
+ return attachment
+ return None # This should never be hit.
+
+ def authenticate(self):
+ if self.authenticated:
+ return
+
+ if self.dryrun:
+ log("Skipping log in for dry run...")
+ self.authenticated = True
+ return
+
+ credentials = Credentials(self.bug_server_host, git_prefix="bugzilla")
+
+ attempts = 0
+ while not self.authenticated:
+ attempts += 1
+ username, password = credentials.read_credentials()
+
+ log("Logging in as %s..." % username)
+ self.browser.open(self.bug_server_url +
+ "index.cgi?GoAheadAndLogIn=1")
+ self.browser.select_form(name="login")
+ self.browser['Bugzilla_login'] = username
+ self.browser['Bugzilla_password'] = password
+ response = self.browser.submit()
+
+ match = re.search("<title>(.+?)</title>", response.read())
+ # If the resulting page has a title, and it contains the word
+ # "invalid" assume it's the login failure page.
+ if match and re.search("Invalid", match.group(1), re.IGNORECASE):
+ errorMessage = "Bugzilla login failed: %s" % match.group(1)
+ # raise an exception only if this was the last attempt
+ if attempts < 5:
+ log(errorMessage)
+ else:
+ raise Exception(errorMessage)
+ else:
+ self.authenticated = True
+ self.username = username
+
+ def _commit_queue_flag(self, mark_for_landing, mark_for_commit_queue):
+ if mark_for_landing:
+ return '+'
+ elif mark_for_commit_queue:
+ return '?'
+ return 'X'
+
+ # FIXME: mark_for_commit_queue and mark_for_landing should be joined into a single commit_flag argument.
+ def _fill_attachment_form(self,
+ description,
+ file_object,
+ mark_for_review=False,
+ mark_for_commit_queue=False,
+ mark_for_landing=False,
+ is_patch=False,
+ filename=None,
+ mimetype=None):
+ self.browser['description'] = description
+ if is_patch:
+ self.browser['ispatch'] = ("1",)
+ # FIXME: Should this use self._find_select_element_for_flag?
+ self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',)
+ self.browser['flag_type-3'] = (self._commit_queue_flag(mark_for_landing, mark_for_commit_queue),)
+
+ filename = filename or "%s.patch" % timestamp()
+ mimetype = mimetype or "text/plain"
+ self.browser.add_file(file_object, mimetype, filename, 'data')
+
+ def _file_object_for_upload(self, file_or_string):
+ if hasattr(file_or_string, 'read'):
+ return file_or_string
+ # Only if file_or_string is not already encoded do we want to encode it.
+ if isinstance(file_or_string, unicode):
+ file_or_string = file_or_string.encode('utf-8')
+ return StringIO.StringIO(file_or_string)
+
+ # timestamp argument is just for unittests.
+ def _filename_for_upload(self, file_object, bug_id, extension="txt", timestamp=timestamp):
+ if hasattr(file_object, "name"):
+ return file_object.name
+ return "bug-%s-%s.%s" % (bug_id, timestamp(), extension)
+
+ def add_attachment_to_bug(self,
+ bug_id,
+ file_or_string,
+ description,
+ filename=None,
+ comment_text=None):
+ self.authenticate()
+ log('Adding attachment "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id)))
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.add_attachment_url(bug_id))
+ self.browser.select_form(name="entryform")
+ file_object = self._file_object_for_upload(file_or_string)
+ filename = filename or self._filename_for_upload(file_object, bug_id)
+ self._fill_attachment_form(description, file_object, filename=filename)
+ if comment_text:
+ log(comment_text)
+ self.browser['comment'] = comment_text
+ self.browser.submit()
+
+ # FIXME: The arguments to this function should be simplified and then
+ # this should be merged into add_attachment_to_bug
+ def add_patch_to_bug(self,
+ bug_id,
+ file_or_string,
+ description,
+ comment_text=None,
+ mark_for_review=False,
+ mark_for_commit_queue=False,
+ mark_for_landing=False):
+ self.authenticate()
+ log('Adding patch "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id)))
+
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.add_attachment_url(bug_id))
+ self.browser.select_form(name="entryform")
+ file_object = self._file_object_for_upload(file_or_string)
+ filename = self._filename_for_upload(file_object, bug_id, extension="patch")
+ self._fill_attachment_form(description,
+ file_object,
+ mark_for_review=mark_for_review,
+ mark_for_commit_queue=mark_for_commit_queue,
+ mark_for_landing=mark_for_landing,
+ is_patch=True,
+ filename=filename)
+ if comment_text:
+ log(comment_text)
+ self.browser['comment'] = comment_text
+ self.browser.submit()
+
+ # FIXME: There has to be a more concise way to write this method.
+ def _check_create_bug_response(self, response_html):
+ match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>",
+ response_html)
+ if match:
+ return match.group('bug_id')
+
+ match = re.search(
+ '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">',
+ response_html,
+ re.DOTALL)
+ error_message = "FAIL"
+ if match:
+ text_lines = BeautifulSoup(
+ match.group('error_message')).findAll(text=True)
+ error_message = "\n" + '\n'.join(
+ [" " + line.strip()
+ for line in text_lines if line.strip()])
+ raise Exception("Bug not created: %s" % error_message)
+
+ def create_bug(self,
+ bug_title,
+ bug_description,
+ component=None,
+ diff=None,
+ patch_description=None,
+ cc=None,
+ blocked=None,
+ assignee=None,
+ mark_for_review=False,
+ mark_for_commit_queue=False):
+ self.authenticate()
+
+ log('Creating bug with title "%s"' % bug_title)
+ if self.dryrun:
+ log(bug_description)
+ # FIXME: This will make some paths fail, as they assume this returns an id.
+ return
+
+ self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit")
+ self.browser.select_form(name="Create")
+ component_items = self.browser.find_control('component').items
+ component_names = map(lambda item: item.name, component_items)
+ if not component:
+ component = "New Bugs"
+ if component not in component_names:
+ component = User.prompt_with_list("Please pick a component:", component_names)
+ self.browser["component"] = [component]
+ if cc:
+ self.browser["cc"] = cc
+ if blocked:
+ self.browser["blocked"] = unicode(blocked)
+ if not assignee:
+ assignee = self.username
+ if assignee and not self.browser.find_control("assigned_to").disabled:
+ self.browser["assigned_to"] = assignee
+ self.browser["short_desc"] = bug_title
+ self.browser["comment"] = bug_description
+
+ if diff:
+ # _fill_attachment_form expects a file-like object
+ # Patch files are already binary, so no encoding needed.
+ assert(isinstance(diff, str))
+ patch_file_object = StringIO.StringIO(diff)
+ self._fill_attachment_form(
+ patch_description,
+ patch_file_object,
+ mark_for_review=mark_for_review,
+ mark_for_commit_queue=mark_for_commit_queue,
+ is_patch=True)
+
+ response = self.browser.submit()
+
+ bug_id = self._check_create_bug_response(response.read())
+ log("Bug %s created." % bug_id)
+ log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id))
+ return bug_id
+
+ def _find_select_element_for_flag(self, flag_name):
+ # FIXME: This will break if we ever re-order attachment flags
+ if flag_name == "review":
+ return self.browser.find_control(type='select', nr=0)
+ elif flag_name == "commit-queue":
+ return self.browser.find_control(type='select', nr=1)
+ raise Exception("Don't know how to find flag named \"%s\"" % flag_name)
+
+ def clear_attachment_flags(self,
+ attachment_id,
+ additional_comment_text=None):
+ self.authenticate()
+
+ comment_text = "Clearing flags on attachment: %s" % attachment_id
+ if additional_comment_text:
+ comment_text += "\n\n%s" % additional_comment_text
+ log(comment_text)
+
+ if self.dryrun:
+ return
+
+ self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
+ self.browser.select_form(nr=1)
+ self.browser.set_value(comment_text, name='comment', nr=0)
+ self._find_select_element_for_flag('review').value = ("X",)
+ self._find_select_element_for_flag('commit-queue').value = ("X",)
+ self.browser.submit()
+
+ def set_flag_on_attachment(self,
+ attachment_id,
+ flag_name,
+ flag_value,
+ comment_text=None,
+ additional_comment_text=None):
+ # FIXME: We need a way to test this function on a live bugzilla
+ # instance.
+
+ self.authenticate()
+
+ if additional_comment_text:
+ comment_text += "\n\n%s" % additional_comment_text
+ log(comment_text)
+
+ if self.dryrun:
+ return
+
+ self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
+ self.browser.select_form(nr=1)
+
+ if comment_text:
+ self.browser.set_value(comment_text, name='comment', nr=0)
+
+ self._find_select_element_for_flag(flag_name).value = (flag_value,)
+ self.browser.submit()
+
+ # FIXME: All of these bug editing methods have a ridiculous amount of
+ # copy/paste code.
+
+ def obsolete_attachment(self, attachment_id, comment_text=None):
+ self.authenticate()
+
+ log("Obsoleting attachment: %s" % attachment_id)
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
+ self.browser.select_form(nr=1)
+ self.browser.find_control('isobsolete').items[0].selected = True
+ # Also clear any review flag (to remove it from review/commit queues)
+ self._find_select_element_for_flag('review').value = ("X",)
+ self._find_select_element_for_flag('commit-queue').value = ("X",)
+ if comment_text:
+ log(comment_text)
+ # Bugzilla has two textareas named 'comment', one is somehow
+ # hidden. We want the first.
+ self.browser.set_value(comment_text, name='comment', nr=0)
+ self.browser.submit()
+
+ def add_cc_to_bug(self, bug_id, email_address_list):
+ self.authenticate()
+
+ log("Adding %s to the CC list for bug %s" % (email_address_list,
+ bug_id))
+ if self.dryrun:
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ self.browser["newcc"] = ", ".join(email_address_list)
+ self.browser.submit()
+
+ def post_comment_to_bug(self, bug_id, comment_text, cc=None):
+ self.authenticate()
+
+ log("Adding comment to bug %s" % bug_id)
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ self.browser["comment"] = comment_text
+ if cc:
+ self.browser["newcc"] = ", ".join(cc)
+ self.browser.submit()
+
+ def close_bug_as_fixed(self, bug_id, comment_text=None):
+ self.authenticate()
+
+ log("Closing bug %s as fixed" % bug_id)
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ if comment_text:
+ self.browser['comment'] = comment_text
+ self.browser['bug_status'] = ['RESOLVED']
+ self.browser['resolution'] = ['FIXED']
+ self.browser.submit()
+
+ def reassign_bug(self, bug_id, assignee, comment_text=None):
+ self.authenticate()
+
+ log("Assigning bug %s to %s" % (bug_id, assignee))
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ if comment_text:
+ log(comment_text)
+ self.browser["comment"] = comment_text
+ self.browser["assigned_to"] = assignee
+ self.browser.submit()
+
+ def reopen_bug(self, bug_id, comment_text):
+ self.authenticate()
+
+ log("Re-opening bug %s" % bug_id)
+ # Bugzilla requires a comment when re-opening a bug, so we know it will
+ # never be None.
+ log(comment_text)
+ if self.dryrun:
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ bug_status = self.browser.find_control("bug_status", type="select")
+ # This is a hack around the fact that ClientForm.ListControl seems to
+ # have no simpler way to ask if a control has an item named "REOPENED"
+ # without using exceptions for control flow.
+ possible_bug_statuses = map(lambda item: item.name, bug_status.items)
+ if "REOPENED" in possible_bug_statuses:
+ bug_status.value = ["REOPENED"]
+ # If the bug was never confirmed it will not have a "REOPENED"
+ # state, but only an "UNCONFIRMED" state.
+ elif "UNCONFIRMED" in possible_bug_statuses:
+ bug_status.value = ["UNCONFIRMED"]
+ else:
+ # FIXME: This logic is slightly backwards. We won't print this
+ # message if the bug is already open with state "UNCONFIRMED".
+ log("Did not reopen bug %s, it appears to already be open with status %s." % (bug_id, bug_status.value))
+ self.browser['comment'] = comment_text
+ self.browser.submit()
diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla_unittest.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla_unittest.py
new file mode 100644
index 0000000..1d08ca5
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla_unittest.py
@@ -0,0 +1,392 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+import datetime
+import StringIO
+
+from .bugzilla import Bugzilla, BugzillaQueries, parse_bug_id
+
+from webkitpy.common.system.outputcapture import OutputCapture
+from webkitpy.tool.mocktool import MockBrowser
+from webkitpy.thirdparty.mock import Mock
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
+
+
+class BugzillaTest(unittest.TestCase):
+ _example_attachment = '''
+ <attachment
+ isobsolete="1"
+ ispatch="1"
+ isprivate="0"
+ >
+ <attachid>33721</attachid>
+ <date>2009-07-29 10:23 PDT</date>
+ <desc>Fixed whitespace issue</desc>
+ <filename>patch</filename>
+ <type>text/plain</type>
+ <size>9719</size>
+ <attacher>christian.plesner.hansen@gmail.com</attacher>
+ <flag name="review"
+ id="17931"
+ status="+"
+ setter="one@test.com"
+ />
+ <flag name="commit-queue"
+ id="17932"
+ status="+"
+ setter="two@test.com"
+ />
+ </attachment>
+'''
+ _expected_example_attachment_parsing = {
+ 'attach_date': datetime.datetime(2009, 07, 29, 10, 23),
+ 'bug_id' : 100,
+ 'is_obsolete' : True,
+ 'is_patch' : True,
+ 'id' : 33721,
+ 'url' : "https://bugs.webkit.org/attachment.cgi?id=33721",
+ 'name' : "Fixed whitespace issue",
+ 'type' : "text/plain",
+ 'review' : '+',
+ 'reviewer_email' : 'one@test.com',
+ 'commit-queue' : '+',
+ 'committer_email' : 'two@test.com',
+ 'attacher_email' : 'christian.plesner.hansen@gmail.com',
+ }
+
+ def test_url_creation(self):
+ # FIXME: These would be all better as doctests
+ bugs = Bugzilla()
+ self.assertEquals(None, bugs.bug_url_for_bug_id(None))
+ self.assertEquals(None, bugs.short_bug_url_for_bug_id(None))
+ self.assertEquals(None, bugs.attachment_url_for_id(None))
+
+ def test_parse_bug_id(self):
+ # FIXME: These would be all better as doctests
+ bugs = Bugzilla()
+ self.assertEquals(12345, parse_bug_id("http://webkit.org/b/12345"))
+ self.assertEquals(12345, parse_bug_id("http://bugs.webkit.org/show_bug.cgi?id=12345"))
+ self.assertEquals(12345, parse_bug_id(bugs.short_bug_url_for_bug_id(12345)))
+ self.assertEquals(12345, parse_bug_id(bugs.bug_url_for_bug_id(12345)))
+ self.assertEquals(12345, parse_bug_id(bugs.bug_url_for_bug_id(12345, xml=True)))
+
+ # Our bug parser is super-fragile, but at least we're testing it.
+ self.assertEquals(None, parse_bug_id("http://www.webkit.org/b/12345"))
+ self.assertEquals(None, parse_bug_id("http://bugs.webkit.org/show_bug.cgi?ctype=xml&id=12345"))
+
+ _bug_xml = """
+ <bug>
+ <bug_id>32585</bug_id>
+ <creation_ts>2009-12-15 15:17 PST</creation_ts>
+ <short_desc>bug to test webkit-patch and commit-queue failures</short_desc>
+ <delta_ts>2009-12-27 21:04:50 PST</delta_ts>
+ <reporter_accessible>1</reporter_accessible>
+ <cclist_accessible>1</cclist_accessible>
+ <classification_id>1</classification_id>
+ <classification>Unclassified</classification>
+ <product>WebKit</product>
+ <component>Tools / Tests</component>
+ <version>528+ (Nightly build)</version>
+ <rep_platform>PC</rep_platform>
+ <op_sys>Mac OS X 10.5</op_sys>
+ <bug_status>NEW</bug_status>
+ <priority>P2</priority>
+ <bug_severity>Normal</bug_severity>
+ <target_milestone>---</target_milestone>
+ <everconfirmed>1</everconfirmed>
+ <reporter name="Eric Seidel">eric@webkit.org</reporter>
+ <assigned_to name="Nobody">webkit-unassigned@lists.webkit.org</assigned_to>
+ <cc>foo@bar.com</cc>
+ <cc>example@example.com</cc>
+ <long_desc isprivate="0">
+ <who name="Eric Seidel">eric@webkit.org</who>
+ <bug_when>2009-12-15 15:17:28 PST</bug_when>
+ <thetext>bug to test webkit-patch and commit-queue failures
+
+Ignore this bug. Just for testing failure modes of webkit-patch and the commit-queue.</thetext>
+ </long_desc>
+ <attachment
+ isobsolete="0"
+ ispatch="1"
+ isprivate="0"
+ >
+ <attachid>45548</attachid>
+ <date>2009-12-27 23:51 PST</date>
+ <desc>Patch</desc>
+ <filename>bug-32585-20091228005112.patch</filename>
+ <type>text/plain</type>
+ <size>10882</size>
+ <attacher>mjs@apple.com</attacher>
+
+ <token>1261988248-dc51409e9c421a4358f365fa8bec8357</token>
+ <data encoding="base64">SW5kZXg6IFdlYktpdC9tYWMvQ2hhbmdlTG9nCj09PT09PT09PT09PT09PT09PT09PT09PT09PT09
+removed-because-it-was-really-long
+ZEZpbmlzaExvYWRXaXRoUmVhc29uOnJlYXNvbl07Cit9CisKIEBlbmQKIAogI2VuZGlmCg==
+</data>
+
+ <flag name="review"
+ id="27602"
+ status="?"
+ setter="mjs@apple.com"
+ />
+ </attachment>
+ </bug>
+"""
+
+ _single_bug_xml = """
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<!DOCTYPE bugzilla SYSTEM "https://bugs.webkit.org/bugzilla.dtd">
+<bugzilla version="3.2.3"
+ urlbase="https://bugs.webkit.org/"
+ maintainer="admin@webkit.org"
+ exporter="eric@webkit.org"
+>
+%s
+</bugzilla>
+""" % _bug_xml
+
+ _expected_example_bug_parsing = {
+ "id" : 32585,
+ "title" : u"bug to test webkit-patch and commit-queue failures",
+ "cc_emails" : ["foo@bar.com", "example@example.com"],
+ "reporter_email" : "eric@webkit.org",
+ "assigned_to_email" : "webkit-unassigned@lists.webkit.org",
+ "bug_status": "NEW",
+ "attachments" : [{
+ "attach_date": datetime.datetime(2009, 12, 27, 23, 51),
+ 'name': u'Patch',
+ 'url' : "https://bugs.webkit.org/attachment.cgi?id=45548",
+ 'is_obsolete': False,
+ 'review': '?',
+ 'is_patch': True,
+ 'attacher_email': 'mjs@apple.com',
+ 'bug_id': 32585,
+ 'type': 'text/plain',
+ 'id': 45548
+ }],
+ }
+
+ # FIXME: This should move to a central location and be shared by more unit tests.
+ def _assert_dictionaries_equal(self, actual, expected):
+ # Make sure we aren't parsing more or less than we expect
+ self.assertEquals(sorted(actual.keys()), sorted(expected.keys()))
+
+ for key, expected_value in expected.items():
+ self.assertEquals(actual[key], expected_value, ("Failure for key: %s: Actual='%s' Expected='%s'" % (key, actual[key], expected_value)))
+
+ def test_parse_bug_dictionary_from_xml(self):
+ bug = Bugzilla()._parse_bug_dictionary_from_xml(self._single_bug_xml)
+ self._assert_dictionaries_equal(bug, self._expected_example_bug_parsing)
+
+ _sample_multi_bug_xml = """
+<bugzilla version="3.2.3" urlbase="https://bugs.webkit.org/" maintainer="admin@webkit.org" exporter="eric@webkit.org">
+ %s
+ %s
+</bugzilla>
+""" % (_bug_xml, _bug_xml)
+
+ def test_parse_bugs_from_xml(self):
+ bugzilla = Bugzilla()
+ bugs = bugzilla._parse_bugs_from_xml(self._sample_multi_bug_xml)
+ self.assertEquals(len(bugs), 2)
+ self.assertEquals(bugs[0].id(), self._expected_example_bug_parsing['id'])
+ bugs = bugzilla._parse_bugs_from_xml("")
+ self.assertEquals(len(bugs), 0)
+
+ # This could be combined into test_bug_parsing later if desired.
+ def test_attachment_parsing(self):
+ bugzilla = Bugzilla()
+ soup = BeautifulSoup(self._example_attachment)
+ attachment_element = soup.find("attachment")
+ attachment = bugzilla._parse_attachment_element(attachment_element, self._expected_example_attachment_parsing['bug_id'])
+ self.assertTrue(attachment)
+ self._assert_dictionaries_equal(attachment, self._expected_example_attachment_parsing)
+
+ _sample_attachment_detail_page = """
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+ <head>
+ <title>
+ Attachment 41073 Details for Bug 27314</title>
+<link rel="Top" href="https://bugs.webkit.org/">
+ <link rel="Up" href="show_bug.cgi?id=27314">
+"""
+
+ def test_attachment_detail_bug_parsing(self):
+ bugzilla = Bugzilla()
+ self.assertEquals(27314, bugzilla._parse_bug_id_from_attachment_page(self._sample_attachment_detail_page))
+
+ def test_add_cc_to_bug(self):
+ bugzilla = Bugzilla()
+ bugzilla.browser = MockBrowser()
+ bugzilla.authenticate = lambda: None
+ expected_stderr = "Adding ['adam@example.com'] to the CC list for bug 42\n"
+ OutputCapture().assert_outputs(self, bugzilla.add_cc_to_bug, [42, ["adam@example.com"]], expected_stderr=expected_stderr)
+
+ def _mock_control_item(self, name):
+ mock_item = Mock()
+ mock_item.name = name
+ return mock_item
+
+ def _mock_find_control(self, item_names=[], selected_index=0):
+ mock_control = Mock()
+ mock_control.items = [self._mock_control_item(name) for name in item_names]
+ mock_control.value = [item_names[selected_index]] if item_names else None
+ return lambda name, type: mock_control
+
+ def _assert_reopen(self, item_names=None, selected_index=None, extra_stderr=None):
+ bugzilla = Bugzilla()
+ bugzilla.browser = MockBrowser()
+ bugzilla.authenticate = lambda: None
+
+ mock_find_control = self._mock_find_control(item_names, selected_index)
+ bugzilla.browser.find_control = mock_find_control
+ expected_stderr = "Re-opening bug 42\n['comment']\n"
+ if extra_stderr:
+ expected_stderr += extra_stderr
+ OutputCapture().assert_outputs(self, bugzilla.reopen_bug, [42, ["comment"]], expected_stderr=expected_stderr)
+
+ def test_reopen_bug(self):
+ self._assert_reopen(item_names=["REOPENED", "RESOLVED", "CLOSED"], selected_index=1)
+ self._assert_reopen(item_names=["UNCONFIRMED", "RESOLVED", "CLOSED"], selected_index=1)
+ extra_stderr = "Did not reopen bug 42, it appears to already be open with status ['NEW'].\n"
+ self._assert_reopen(item_names=["NEW", "RESOLVED"], selected_index=0, extra_stderr=extra_stderr)
+
+ def test_file_object_for_upload(self):
+ bugzilla = Bugzilla()
+ file_object = StringIO.StringIO()
+ unicode_tor = u"WebKit \u2661 Tor Arne Vestb\u00F8!"
+ utf8_tor = unicode_tor.encode("utf-8")
+ self.assertEqual(bugzilla._file_object_for_upload(file_object), file_object)
+ self.assertEqual(bugzilla._file_object_for_upload(utf8_tor).read(), utf8_tor)
+ self.assertEqual(bugzilla._file_object_for_upload(unicode_tor).read(), utf8_tor)
+
+ def test_filename_for_upload(self):
+ bugzilla = Bugzilla()
+ mock_file = Mock()
+ mock_file.name = "foo"
+ self.assertEqual(bugzilla._filename_for_upload(mock_file, 1234), 'foo')
+ mock_timestamp = lambda: "now"
+ filename = bugzilla._filename_for_upload(StringIO.StringIO(), 1234, extension="patch", timestamp=mock_timestamp)
+ self.assertEqual(filename, "bug-1234-now.patch")
+
+
+class BugzillaQueriesTest(unittest.TestCase):
+ _sample_request_page = """
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+ <head>
+ <title>Request Queue</title>
+ </head>
+<body>
+
+<h3>Flag: review</h3>
+ <table class="requests" cellspacing="0" cellpadding="4" border="1">
+ <tr>
+ <th>Requester</th>
+ <th>Requestee</th>
+ <th>Bug</th>
+ <th>Attachment</th>
+ <th>Created</th>
+ </tr>
+ <tr>
+ <td>Shinichiro Hamaji &lt;hamaji&#64;chromium.org&gt;</td>
+ <td></td>
+ <td><a href="show_bug.cgi?id=30015">30015: text-transform:capitalize is failing in CSS2.1 test suite</a></td>
+ <td><a href="attachment.cgi?id=40511&amp;action=review">
+40511: Patch v0</a></td>
+ <td>2009-10-02 04:58 PST</td>
+ </tr>
+ <tr>
+ <td>Zan Dobersek &lt;zandobersek&#64;gmail.com&gt;</td>
+ <td></td>
+ <td><a href="show_bug.cgi?id=26304">26304: [GTK] Add controls for playing html5 video.</a></td>
+ <td><a href="attachment.cgi?id=40722&amp;action=review">
+40722: Media controls, the simple approach</a></td>
+ <td>2009-10-06 09:13 PST</td>
+ </tr>
+ <tr>
+ <td>Zan Dobersek &lt;zandobersek&#64;gmail.com&gt;</td>
+ <td></td>
+ <td><a href="show_bug.cgi?id=26304">26304: [GTK] Add controls for playing html5 video.</a></td>
+ <td><a href="attachment.cgi?id=40723&amp;action=review">
+40723: Adjust the media slider thumb size</a></td>
+ <td>2009-10-06 09:15 PST</td>
+ </tr>
+ </table>
+</body>
+</html>
+"""
+ _sample_quip_page = u"""
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+ <head>
+ <title>Bugzilla Quip System</title>
+ </head>
+ <body>
+ <h2>
+
+ Existing quips:
+ </h2>
+ <ul>
+ <li>Everything should be made as simple as possible, but not simpler. - Albert Einstein</li>
+ <li>Good artists copy. Great artists steal. - Pablo Picasso</li>
+ <li>\u00e7gua mole em pedra dura, tanto bate at\u008e que fura.</li>
+
+ </ul>
+ </body>
+</html>
+"""
+
+ def _assert_result_count(self, queries, html, count):
+ self.assertEquals(queries._parse_result_count(html), count)
+
+ def test_parse_result_count(self):
+ queries = BugzillaQueries(None)
+ # Pages with results, always list the count at least twice.
+ self._assert_result_count(queries, '<span class="bz_result_count">314 bugs found.</span><span class="bz_result_count">314 bugs found.</span>', 314)
+ self._assert_result_count(queries, '<span class="bz_result_count">Zarro Boogs found.</span>', 0)
+ self._assert_result_count(queries, '<span class="bz_result_count">\n \nOne bug found.</span>', 1)
+ self.assertRaises(Exception, queries._parse_result_count, ['Invalid'])
+
+ def test_request_page_parsing(self):
+ queries = BugzillaQueries(None)
+ self.assertEquals([40511, 40722, 40723], queries._parse_attachment_ids_request_query(self._sample_request_page))
+
+ def test_quip_page_parsing(self):
+ queries = BugzillaQueries(None)
+ expected_quips = ["Everything should be made as simple as possible, but not simpler. - Albert Einstein", "Good artists copy. Great artists steal. - Pablo Picasso", u"\u00e7gua mole em pedra dura, tanto bate at\u008e que fura."]
+ self.assertEquals(expected_quips, queries._parse_quips(self._sample_quip_page))
+
+ def test_load_query(self):
+ queries = BugzillaQueries(Mock())
+ queries._load_query("request.cgi?action=queue&type=review&group=type")