diff options
Diffstat (limited to 'WebKitTools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py')
-rw-r--r-- | WebKitTools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py | 674 |
1 files changed, 0 insertions, 674 deletions
diff --git a/WebKitTools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py b/WebKitTools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py deleted file mode 100644 index 9fa7fe5..0000000 --- a/WebKitTools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py +++ /dev/null @@ -1,674 +0,0 @@ -# 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 - -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 - - # Note: _load_query and _fetch_bug are the only two 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_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_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 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_bug_page(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")) - 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_page(self._fetch_bug_page(bug_id)) - except KeyboardInterrupt: - raise - except: - self.authenticate() - return self._parse_bug_page(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 _fill_attachment_form(self, - description, - patch_file_object, - comment_text=None, - mark_for_review=False, - mark_for_commit_queue=False, - mark_for_landing=False, - bug_id=None): - self.browser['description'] = description - self.browser['ispatch'] = ("1",) - self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',) - - if mark_for_landing: - self.browser['flag_type-3'] = ('+',) - elif mark_for_commit_queue: - self.browser['flag_type-3'] = ('?',) - else: - self.browser['flag_type-3'] = ('X',) - - if bug_id: - patch_name = "bug-%s-%s.patch" % (bug_id, timestamp()) - else: - patch_name ="%s.patch" % timestamp() - - self.browser.add_file(patch_file_object, - "text/plain", - patch_name, - 'data') - - def add_patch_to_bug(self, - bug_id, - diff, - description, - comment_text=None, - mark_for_review=False, - mark_for_commit_queue=False, - mark_for_landing=False): - self.authenticate() - - log('Adding patch "%s" to %sshow_bug.cgi?id=%s' % (description, - self.bug_server_url, - bug_id)) - - if self.dryrun: - log(comment_text) - return - - self.browser.open("%sattachment.cgi?action=enter&bugid=%s" % ( - self.bug_server_url, bug_id)) - self.browser.select_form(name="entryform") - - # _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(description, - patch_file_object, - mark_for_review=mark_for_review, - mark_for_commit_queue=mark_for_commit_queue, - mark_for_landing=mark_for_landing, - bug_id=bug_id) - if comment_text: - log(comment_text) - self.browser['comment'] = comment_text - self.browser.submit() - - 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) - 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 assignee == None: - 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) - - 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() |