blob: fe81b48124149bcee5fc5219aaed93d95618225e [file] [log] [blame]
# Copyright (c) 2009, Google Inc. All rights reserved.
# Copyright (c) 2009 Apple 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.
#
# WebKit's Python module for interacting with Bugzilla
import getpass
import platform
import re
import subprocess
import urllib2
from datetime import datetime # used in timestamp()
# Import WebKit-specific modules.
from modules.logging import error, log
from modules.committers import CommitterList
# WebKit includes a built copy of BeautifulSoup in Scripts/modules
# so this import should always succeed.
from .BeautifulSoup import BeautifulSoup
try:
from mechanize import Browser
except ImportError, e:
print """
mechanize is required.
To install:
sudo easy_install mechanize
Or from the web:
http://wwwsearch.sourceforge.net/mechanize/
"""
exit(1)
def credentials_from_git():
return [read_config("username"), read_config("password")]
def credentials_from_keychain(username=None):
if not is_mac_os_x():
return [username, None]
command = "/usr/bin/security %s -g -s %s" % ("find-internet-password", Bugzilla.bug_server_host)
if username:
command += " -a %s" % username
log('Reading Keychain for %s account and password. Click "Allow" to continue...' % Bugzilla.bug_server_host)
keychain_process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True)
value = keychain_process.communicate()[0]
exit_code = keychain_process.wait()
if exit_code:
return [username, None]
match = re.search('^\s*"acct"<blob>="(?P<username>.+)"', value, re.MULTILINE)
if match:
username = match.group('username')
password = None
match = re.search('^password: "(?P<password>.+)"', value, re.MULTILINE)
if match:
password = match.group('password')
return [username, password]
def is_mac_os_x():
return platform.mac_ver()[0]
def parse_bug_id(message):
match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message)
if match:
return match.group('bug_id')
match = re.search(Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", message)
if match:
return match.group('bug_id')
return None
# FIXME: This should not depend on git for config storage
def read_config(key):
# Need a way to read from svn too
config_process = subprocess.Popen("git config --get bugzilla.%s" % key, stdout=subprocess.PIPE, shell=True)
value = config_process.communicate()[0]
return_code = config_process.wait()
if return_code:
return None
return value.rstrip('\n')
def read_credentials():
(username, password) = credentials_from_git()
if not username or not password:
(username, password) = credentials_from_keychain(username)
if not username:
username = raw_input("Bugzilla login: ")
if not password:
password = getpass.getpass("Bugzilla password for %s: " % username)
return [username, password]
def timestamp():
return datetime.now().strftime("%Y%m%d%H%M%S")
class BugzillaError(Exception):
pass
class Bugzilla:
def __init__(self, dryrun=False, committers=CommitterList()):
self.dryrun = dryrun
self.authenticated = False
self.browser = Browser()
# Ignore bugs.webkit.org/robots.txt until we fix it to allow this script
self.browser.set_handle_robots(False)
self.committers = committers
# Defaults (until we support better option parsing):
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 bug_url_for_bug_id(self, bug_id, xml=False):
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):
return "http://webkit.org/b/%s" % bug_id
def attachment_url_for_id(self, attachment_id, action="view"):
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 and flag['status'] == '+':
attachment[result_key] = flag['setter']
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'] = str(element.find('attachid').string)
attachment['url'] = self.attachment_url_for_id(attachment['id'])
attachment['name'] = unicode(element.find('desc').string)
attachment['type'] = str(element.find('type').string)
self._parse_attachment_flag(element, 'review', attachment, 'reviewer_email')
self._parse_attachment_flag(element, 'commit-queue', attachment, 'committer_email')
return attachment
def fetch_attachments_from_bug(self, bug_id):
bug_url = self.bug_url_for_bug_id(bug_id, xml=True)
log("Fetching: %s" % bug_url)
page = urllib2.urlopen(bug_url)
soup = BeautifulSoup(page)
attachments = []
for element in soup.findAll('attachment'):
attachment = self._parse_attachment_element(element, bug_id)
attachments.append(attachment)
return attachments
def fetch_title_from_bug(self, bug_id):
bug_url = self.bug_url_for_bug_id(bug_id, xml=True)
page = urllib2.urlopen(bug_url)
soup = BeautifulSoup(page)
return soup.find('short_desc').string
def fetch_patches_from_bug(self, bug_id):
patches = []
for attachment in self.fetch_attachments_from_bug(bug_id):
if attachment['is_patch'] and not attachment['is_obsolete']:
patches.append(attachment)
return patches
# _view_source_link belongs in some sort of webkit_config.py module.
def _view_source_link(self, local_path):
return "http://trac.webkit.org/browser/trunk/%s" % local_path
def _validate_setter_email(self, patch, result_key, lookup_function, rejection_function, reject_invalid_patches):
setter_email = patch.get(result_key + '_email')
if not setter_email:
return None
committer = lookup_function(setter_email)
if committer:
patch[result_key] = committer.full_name
return patch[result_key]
if reject_invalid_patches:
committer_list = "WebKitTools/Scripts/modules/committers.py"
failure_message = "%s does not have %s permissions according to %s." % (setter_email, result_key, self._view_source_link(committer_list))
rejection_function(patch['id'], failure_message)
else:
log("Warning, attachment %s on bug %s has invalid %s (%s)", (patch['id'], patch['bug_id'], result_key, setter_email))
return None
def _validate_reviewer(self, patch, reject_invalid_patches):
return self._validate_setter_email(patch, 'reviewer', self.committers.reviewer_by_bugzilla_email, self.reject_patch_from_review_queue, reject_invalid_patches)
def _validate_committer(self, patch, reject_invalid_patches):
return self._validate_setter_email(patch, 'committer', self.committers.committer_by_bugzilla_email, self.reject_patch_from_commit_queue, reject_invalid_patches)
def fetch_reviewed_patches_from_bug(self, bug_id, reject_invalid_patches=False):
reviewed_patches = []
for attachment in self.fetch_attachments_from_bug(bug_id):
if self._validate_reviewer(attachment, reject_invalid_patches) and not attachment['is_obsolete']:
reviewed_patches.append(attachment)
return reviewed_patches
def fetch_commit_queue_patches_from_bug(self, bug_id, reject_invalid_patches=False):
commit_queue_patches = []
for attachment in self.fetch_reviewed_patches_from_bug(bug_id, reject_invalid_patches):
if self._validate_committer(attachment, reject_invalid_patches) and not attachment['is_obsolete']:
commit_queue_patches.append(attachment)
return commit_queue_patches
def fetch_bug_ids_from_commit_queue(self):
commit_queue_url = self.bug_server_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"
page = urllib2.urlopen(commit_queue_url)
soup = BeautifulSoup(page)
bug_ids = []
# Grab the cells in the first column (which happens to be the bug ids)
for bug_link_cell in soup('td', "first-child"): # tds with the class "first-child"
bug_link = bug_link_cell.find("a")
bug_ids.append(bug_link.string) # the contents happen to be the bug id
return bug_ids
def fetch_patches_from_commit_queue(self, reject_invalid_patches=False):
patches_to_land = []
for bug_id in self.fetch_bug_ids_from_commit_queue():
patches = self.fetch_commit_queue_patches_from_bug(bug_id, reject_invalid_patches)
patches_to_land += patches
return patches_to_land
def authenticate(self):
if self.authenticated:
return
if self.dryrun:
log("Skipping log in for dry run...")
self.authenticated = True
return
(username, password) = 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):
# FIXME: We could add the ability to try again on failure.
raise BugzillaError("Bugzilla login failed: %s" % match.group(1))
self.authenticated = True
def _fill_attachment_form(self, description, patch_file_object, comment_text=None, mark_for_review=False, mark_for_commit_queue=False, bug_id=None):
self.browser['description'] = description
self.browser['ispatch'] = ("1",)
self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',)
self.browser['flag_type-3'] = ('?',) if mark_for_commit_queue else ('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, patch_file_object, description, comment_text=None, mark_for_review=False, mark_for_commit_queue=False):
self.authenticate()
log('Adding patch "%s" to bug %s' % (description, 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")
self._fill_attachment_form(description, patch_file_object, mark_for_review=mark_for_review, mark_for_commit_queue=mark_for_commit_queue, bug_id=bug_id)
if comment_text:
log(comment_text)
self.browser['comment'] = comment_text
self.browser.submit()
def prompt_for_component(self, components):
log("Please pick a component:")
i = 0
for name in components:
i += 1
log("%2d. %s" % (i, name))
result = int(raw_input("Enter a number: ")) - 1
return components[result]
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 BugzillaError("Bug not created: %s" % error_message)
def create_bug_with_patch(self, bug_title, bug_description, component, patch_file_object, patch_description, cc, mark_for_review=False, mark_for_commit_queue=False):
self.authenticate()
log('Creating bug with patch description "%s"' % patch_description)
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 or component not in component_names:
component = self.prompt_for_component(component_names)
self.browser['component'] = [component]
if cc:
self.browser['cc'] = cc
self.browser['short_desc'] = bug_title
if bug_description:
log(bug_description)
self.browser['comment'] = bug_description
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)
if 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()
# FIXME: We need a way to test this on a live bugzilla instance.
def _set_flag_on_attachment(self, attachment_id, flag_name, flag_value, comment_text, additional_comment_text):
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)
self.browser.set_value(comment_text, name='comment', nr=0)
self._find_select_element_for_flag(flag_name).value = (flag_value,)
self.browser.submit()
def reject_patch_from_commit_queue(self, attachment_id, additional_comment_text=None):
comment_text = "Rejecting patch %s from commit-queue." % attachment_id
self._set_flag_on_attachment(attachment_id, 'commit-queue', '-', comment_text, additional_comment_text)
def reject_patch_from_review_queue(self, attachment_id, additional_comment_text=None):
comment_text = "Rejecting patch %s from review queue." % attachment_id
self._set_flag_on_attachment(attachment_id, 'review', '-', comment_text, additional_comment_text)
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 post_comment_to_bug(self, bug_id, comment_text):
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
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:
log(comment_text)
self.browser['comment'] = comment_text
self.browser['bug_status'] = ['RESOLVED']
self.browser['resolution'] = ['FIXED']
self.browser.submit()
def reopen_bug(self, bug_id, comment_text):
self.authenticate()
log("Re-opening bug %s" % bug_id)
log(comment_text) # Bugzilla requires a comment when re-opening a bug, so we know it will never be None.
if self.dryrun:
return
self.browser.open(self.bug_url_for_bug_id(bug_id))
self.browser.select_form(name="changeform")
self.browser['bug_status'] = ['REOPENED']
self.browser['comment'] = comment_text
self.browser.submit()