Diff
Modified: trunk/Tools/ChangeLog (225736 => 225737)
--- trunk/Tools/ChangeLog 2017-12-11 03:10:41 UTC (rev 225736)
+++ trunk/Tools/ChangeLog 2017-12-11 05:04:33 UTC (rev 225737)
@@ -1,3 +1,81 @@
+2017-12-10 Youenn Fablet <[email protected]>
+
+ Add a script to automate W3c web-platform-tests pull request creations from WebKit commits
+ https://bugs.webkit.org/show_bug.cgi?id=169462
+
+ Reviewed by Darin Adler.
+
+ Adding some git helper routines used by WPT exporter.
+ Copying WPT github utility files from Chromium.
+ Updating web.py/web_mock.py to cope with these new files.
+
+ Implementing test exporter in test_exporter.py.
+ This script takes a WebKit commit as input and will create a WPT commit in a local WPT clone.
+ It will then push the commit to a public GitHub repository identified by a username parameter.
+ This parameter is passed through the command line or searched through git config/environment variables.
+
+ The script can optionally create a pull request to the official WPT GitHub repository.
+ User must provide a GitHub token to allow the script to make the PR on behalf of the user.
+ In that case, a comment is added to the corresponding bugzilla if a bug ID is given or can be found from the change log,
+ to easily link the pull request with the bugzilla bug.
+
+ * Scripts/export-w3c-test-changes: Added.
+ * Scripts/webkitpy/common/checkout/scm/git.py:
+ (Git.reset_hard):
+ (Git):
+ (Git.am):
+ (Git.commit):
+ (Git.format_patch):
+ (Git.request_pull):
+ (Git.remote):
+ (Git.push):
+ (Git.checkout_new_branch):
+ * Scripts/webkitpy/common/net/web.py:
+ (Web.request): Copied from Chromium.
+ * Scripts/webkitpy/common/net/web_mock.py: Copied needed code from Chromium.
+ * Scripts/webkitpy/w3c/test_exporter.py: Added.
+ (TestExporter):
+ (TestExporter.__init__):
+ (TestExporter._init_repository):
+ (TestExporter.download_and_commit_patch):
+ (TestExporter.clean):
+ (TestExporter.create_branch_with_patch):
+ (TestExporter.push_to_public_repository):
+ (TestExporter.make_pull_request):
+ (TestExporter.delete_local_branch):
+ (TestExporter.create_git_patch):
+ (TestExporter.create_upload_remote_if_needed):
+ (TestExporter.do_export):
+ (parse_args):
+ (configure_logging):
+ (configure_logging.LogHandler):
+ (configure_logging.LogHandler.format):
+ (main):
+ * Scripts/webkitpy/w3c/test_exporter_unittest.py: Added.
+ (TestExporterTest):
+ (TestExporterTest.MockBugzilla):
+ (TestExporterTest.MockBugzilla.__init__):
+ (TestExporterTest.MockBugzilla.fetch_bug_dictionary):
+ (TestExporterTest.MockBugzilla.post_comment_to_bug):
+ (TestExporterTest.MockGit):
+ (TestExporterTest.MockGit.clone):
+ (TestExporterTest.MockGit.__init__):
+ (TestExporterTest.MockGit.fetch):
+ (TestExporterTest.MockGit.checkout):
+ (TestExporterTest.MockGit.reset_hard):
+ (TestExporterTest.MockGit.push):
+ (TestExporterTest.MockGit.format_patch):
+ (TestExporterTest.MockGit.delete_branch):
+ (TestExporterTest.MockGit.checkout_new_branch):
+ (TestExporterTest.MockGit.am):
+ (TestExporterTest.MockGit.commit):
+ (TestExporterTest.MockGit.remote):
+ (TestExporterTest.test_export):
+ * Scripts/webkitpy/w3c/common.py: Copied from chromium.
+ * Scripts/webkitpy/w3c/wpt_github.py: Copied from chromium.
+ * Scripts/webkitpy/w3c/wpt_github_mock.py: Copied from chromium.
+ * Scripts/webkitpy/w3c/wpt_github_unittest.py: Copied from chromium.
+
2017-12-10 Konstantin Tokarev <[email protected]>
[python] Modernize "except" usage for python3 compatibility
Added: trunk/Tools/Scripts/export-w3c-test-changes (0 => 225737)
--- trunk/Tools/Scripts/export-w3c-test-changes (rev 0)
+++ trunk/Tools/Scripts/export-w3c-test-changes 2017-12-11 05:04:33 UTC (rev 225737)
@@ -0,0 +1,35 @@
+#!/usr/bin/env python
+
+# Copyright (C) 2017 Apple Incorporated. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# 1. Redistributions of source code must retain the above
+# copyright notice, this list of conditions and the following
+# disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER "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 HOLDER 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 sys
+
+from webkitpy.w3c import test_exporter
+
+
+sys.exit(test_exporter.main(sys.argv[1:], sys.stdout, sys.stderr))
Property changes on: trunk/Tools/Scripts/export-w3c-test-changes
___________________________________________________________________
Added: svn:executable
+*
\ No newline at end of property
Modified: trunk/Tools/Scripts/webkitpy/common/checkout/scm/git.py (225736 => 225737)
--- trunk/Tools/Scripts/webkitpy/common/checkout/scm/git.py 2017-12-11 03:10:41 UTC (rev 225736)
+++ trunk/Tools/Scripts/webkitpy/common/checkout/scm/git.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -29,7 +29,6 @@
import datetime
import logging
-import os
import re
from webkitpy.common.memoized import memoized
@@ -37,7 +36,7 @@
from .commitmessage import CommitMessage
from .scm import AuthenticationError, SCM, commit_error_handler
-from .svn import SVN, SVNRepository
+from .svn import SVNRepository
_log = logging.getLogger(__name__)
@@ -507,11 +506,11 @@
def deinit_submodules(self):
return self._run_git(['submodule', 'deinit', '-f', '.'])
- def _branch_ref_exists(self, branch_ref):
- return self._run_git(['show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0
+ def branch_ref_exists(self, branch_ref):
+ return self._run_git(['show-ref', '--quiet', '--verify', 'refs/heads/' + branch_ref], return_exit_code=True) == 0
def delete_branch(self, branch_name):
- if self._branch_ref_exists('refs/heads/' + branch_name):
+ if self.branch_ref_exists(branch_name):
self._run_git(['branch', '-D', branch_name])
def remote_merge_base(self):
@@ -522,7 +521,7 @@
remote_branch_refs = self.read_git_config('svn-remote.svn.fetch', cwd=self.checkout_root, executive=self._executive)
if not remote_branch_refs:
remote_master_ref = 'refs/remotes/origin/master'
- if not self._branch_ref_exists(remote_master_ref):
+ if not self.branch_ref_exists(remote_master_ref):
raise ScriptError(message="Can't find a branch to diff against. svn-remote.svn.fetch is not in the git config and %s does not exist" % remote_master_ref)
return remote_master_ref
@@ -584,6 +583,37 @@
def fetch(self, remote='origin'):
return self._run_git(['fetch', remote])
+ # Reset current HEAD to the specified commit.
+ def reset_hard(self, commit):
+ return self._run_git(['reset', '--hard', commit])
+
+ def apply_mail_patch(self, options):
+ return self._run_git(['apply'] + options)
+
+ def commit(self, options):
+ return self._run_git(['commit'] + options)
+
+ def format_patch(self, options):
+ return self._run_git(['format-patch'] + options)
+
+ def request_pull(self, options):
+ return self._run_git(['request-pull'] + options)
+
+ def remote(self, options):
+ return self._run_git(['remote'] + options)
+
+ def push(self, options):
+ return self._run_git(['push'] + options)
+
+ def local_config(self, key):
+ return self._run_git(['config', '--get', '--local', key], error_handler=Executive.ignore_error)
+
+ def set_local_config(self, key, value):
+ return self._run_git(['config', '--add', '--local', key, value], error_handler=Executive.ignore_error)
+
+ def checkout_new_branch(self, branch_name):
+ return self._run_git(['checkout', '-b', branch_name])
+
def checkout(self, revision, quiet=None):
command = ['checkout', revision]
if quiet:
Modified: trunk/Tools/Scripts/webkitpy/common/net/web.py (225736 => 225737)
--- trunk/Tools/Scripts/webkitpy/common/net/web.py 2017-12-11 03:10:41 UTC (rev 225736)
+++ trunk/Tools/Scripts/webkitpy/common/net/web.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -34,3 +34,13 @@
class Web(object):
def get_binary(self, url, convert_404_to_None=False):
return NetworkTransaction(convert_404_to_None=convert_404_to_None).run(lambda: urllib2.urlopen(url).read())
+
+ def request(self, method, url, data, headers=None):
+ opener = urllib2.build_opener(urllib2.HTTPHandler)
+ request = urllib2.Request(url="" data=""
+ request.get_method = lambda: method
+
+ if headers:
+ for key, value in headers.items():
+ request.add_header(key, value)
+ return opener.open(request)
Modified: trunk/Tools/Scripts/webkitpy/common/net/web_mock.py (225736 => 225737)
--- trunk/Tools/Scripts/webkitpy/common/net/web_mock.py 2017-12-11 03:10:41 UTC (rev 225736)
+++ trunk/Tools/Scripts/webkitpy/common/net/web_mock.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -27,12 +27,13 @@
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import StringIO
+import urllib2
-
class MockWeb(object):
- def __init__(self, urls=None):
+ def __init__(self, urls=None, responses=[]):
self.urls = urls or {}
self.urls_fetched = []
+ self.responses = responses
def get_binary(self, url, convert_404_to_None=False):
self.urls_fetched.append(url)
@@ -40,7 +41,30 @@
return self.urls[url]
return "MOCK Web result, convert 404 to None=%s" % convert_404_to_None
+ def request(self, method, url, data, headers=None): # pylint: disable=unused-argument
+ return MockResponse(self.responses.pop(0))
+
+class MockResponse(object):
+ def __init__(self, values):
+ self.status_code = values['status_code']
+ self.url = ''
+ self.body = values.get('body', '')
+
+ if int(self.status_code) >= 400:
+ raise urllib2.HTTPError(
+ url=""
+ code=self.status_code,
+ msg='Received error status code: {}'.format(self.status_code),
+ hdrs={},
+ fp=None)
+
+ def getcode(self):
+ return self.status_code
+
+ def read(self):
+ return self.body
+
# FIXME: Classes which are using Browser probably want to use Web instead.
class MockBrowser(object):
params = {}
Added: trunk/Tools/Scripts/webkitpy/w3c/common.py (0 => 225737)
--- trunk/Tools/Scripts/webkitpy/w3c/common.py (rev 0)
+++ trunk/Tools/Scripts/webkitpy/w3c/common.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -0,0 +1,99 @@
+# Copyright 2016 The Chromium Authors. 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.
+
+"""Utility functions used both when importing and exporting."""
+
+import json
+import logging
+
+
+WPT_GH_ORG = 'w3c'
+WPT_GH_REPO_NAME = 'web-platform-tests'
+WPT_GH_URL = 'https://github.com/%s/%s/' % (WPT_GH_ORG, WPT_GH_REPO_NAME)
+WPT_MIRROR_URL = 'https://chromium.googlesource.com/external/w3c/web-platform-tests.git'
+WPT_GH_SSH_URL_TEMPLATE = 'https://{}@github.com/%s/%s.git' % (WPT_GH_ORG, WPT_GH_REPO_NAME)
+WPT_REVISION_FOOTER = 'WPT-Export-Revision:'
+EXPORT_PR_LABEL = 'chromium-export'
+PROVISIONAL_PR_LABEL = 'do not merge yet'
+
+# TODO(qyearsley): Avoid hard-coding third_party/WebKit/LayoutTests.
+CHROMIUM_WPT_DIR = 'third_party/WebKit/LayoutTests/external/wpt/'
+
+_log = logging.getLogger(__name__)
+
+
+def read_credentials(host, credentials_json):
+ """Extracts credentials from a JSON file."""
+ if not credentials_json:
+ return {}
+ if not host.filesystem.exists(credentials_json):
+ _log.warning('Credentials JSON file not found at %s.', credentials_json)
+ return {}
+ credentials = {}
+ contents = json.loads(host.filesystem.read_text_file(credentials_json))
+ for key in ('GH_USER', 'GH_TOKEN', 'GERRIT_USER', 'GERRIT_TOKEN'):
+ if key in contents:
+ credentials[key] = contents[key]
+ return credentials
+
+
+def is_testharness_baseline(filename):
+ """Checks whether a given file name appears to be a testharness baseline.
+
+ Args:
+ filename: A path (absolute or relative) or a basename.
+ """
+ return filename.endswith('-expected.txt')
+
+
+def is_basename_skipped(basename):
+ """Checks whether to skip (not sync) a file based on its basename.
+
+ Note: this function is used during both import and export, i.e., files with
+ skipped basenames are never imported or exported.
+ """
+ assert '/' not in basename
+ blacklist = [
+ 'MANIFEST.json', # MANIFEST.json is automatically regenerated.
+ 'OWNERS', # https://crbug.com/584660 https://crbug.com/702283
+ 'reftest.list', # https://crbug.com/582838
+ ]
+ return (basename in blacklist
+ or is_testharness_baseline(basename)
+ or basename.startswith('.'))
+
+
+def is_file_exportable(path):
+ """Checks whether a file in Chromium WPT should be exported to upstream.
+
+ Args:
+ path: A relative path from the root of Chromium repository.
+ """
+ assert path.startswith(CHROMIUM_WPT_DIR)
+ basename = path[path.rfind('/') + 1:]
+ return not is_basename_skipped(basename)
Added: trunk/Tools/Scripts/webkitpy/w3c/test_exporter.py (0 => 225737)
--- trunk/Tools/Scripts/webkitpy/w3c/test_exporter.py (rev 0)
+++ trunk/Tools/Scripts/webkitpy/w3c/test_exporter.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -0,0 +1,293 @@
+# Copyright (c) 2017, 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:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS 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 APPLE INC. AND ITS 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.
+
+"""
+ This script uploads changes made to W3C web-platform-tests tests.
+"""
+
+import argparse
+import logging
+import os
+import time
+
+from webkitpy.common.checkout.scm.git import Git
+from webkitpy.common.host import Host
+from webkitpy.common.net.bugzilla import Bugzilla
+from webkitpy.common.webkit_finder import WebKitFinder
+from webkitpy.w3c.wpt_github import WPTGitHub
+
+_log = logging.getLogger(__name__)
+
+WEBKIT_WPT_DIR = 'LayoutTests/imported/w3c/web-platform-tests'
+WPT_PR_URL = "https://github.com/w3c/web-platform-tests/pull/"
+
+
+class TestExporter(object):
+
+ def __init__(self, host, options, gitClass=Git, bugzillaClass=Bugzilla, WPTGitHubClass=WPTGitHub):
+ self._host = host
+ self._filesystem = host.filesystem
+ self._options = options
+
+ self._host.initialize_scm()
+
+ self._bugzilla = bugzillaClass()
+ self._bug_id = options.bug_id
+ if not self._bug_id:
+ if options.attachment_id:
+ self._bug_id = self._bugzilla.bug_id_for_attachment_id(options.attachment_id)
+ elif options.git_commit:
+ self._bug_id = self._host.checkout().bug_id_for_this_commit(options.git_commit)
+
+ if not self._options.repository_directory:
+ webkit_finder = WebKitFinder(self._filesystem)
+ self._options.repository_directory = webkit_finder.path_from_webkit_base('WebKitBuild', 'w3c-tests', 'web-platform-tests')
+
+ self._git = self._ensure_wpt_repository("https://github.com/w3c/web-platform-tests.git", self._options.repository_directory, gitClass)
+
+ self._username = options.username
+ if not self._username:
+ self._username = self._git.local_config('github.username').rstrip()
+ if not self._username:
+ self._username = os.environ.get('GITHUB_USERNAME')
+ if not self._username:
+ raise ValueError("Missing GitHub username, please provide it as a parameter.")
+ elif not self._git.local_config('github.username'):
+ self._git.set_local_config('github.username', self._username)
+
+ self._token = options.token
+ if not self._token:
+ self._token = self._git.local_config('github.token').rstrip()
+ if not self._token:
+ self._token = os.environ.get('GITHUB_TOKEN')
+ if not self._token:
+ _log.info("Missing GitHub token, the script will not be able to create a pull request to W3C web-platform-tests repository.")
+ elif not self._git.local_config('github.token'):
+ self._git.set_local_config('github.token', self._token)
+
+ self._github = WPTGitHubClass(self._host, self._username, self._token) if self._username and self._token else None
+
+ self._branch_name = self._ensure_new_branch_name()
+ self._public_branch_name = options.public_branch_name if options.public_branch_name else self._branch_name
+ self._bugzilla_url = "https://bugs.webkit.org/show_bug.cgi?id=" + str(self._bug_id)
+ self._commit_message = options.message if options.message else 'WebKit export of ' + self._bugzilla_url
+
+ self._wpt_fork_remote = options.repository_remote
+ if not self._wpt_fork_remote:
+ self._wpt_fork_remote = self._username
+
+ self._wpt_fork_push_url = options.repository_remote_url
+ if not self._wpt_fork_push_url:
+ self._wpt_fork_push_url = "https://" + self._username + "@github.com/" + self._username + "/web-platform-tests.git"
+
+ def _ensure_wpt_repository(self, url, wpt_repository_directory, gitClass):
+ git = None
+ if not self._filesystem.exists(wpt_repository_directory):
+ _log.info('Cloning %s into %s...' % (url, wpt_repository_directory))
+ gitClass.clone(url, wpt_repository_directory, self._host.executive)
+ git = gitClass(wpt_repository_directory, None, executive=self._host.executive, filesystem=self._filesystem)
+ return git
+
+ def _fetch_wpt_repository(self):
+ _log.info('Fetching web-platform-tests repository')
+ self._git.fetch()
+
+ def _ensure_new_branch_name(self):
+ branch_name_prefix = "wpt-export-for-webkit-" + (str(self._bug_id) if self._bug_id else "0")
+ branch_name = branch_name_prefix
+ counter = 0
+ while self._git.branch_ref_exists(branch_name):
+ branch_name = ("%s-%s") % (branch_name_prefix, str(counter))
+ counter = counter + 1
+ return branch_name
+
+ def download_and_commit_patch(self):
+ if self._options.git_commit:
+ return True
+
+ patch_options = ["--no-update", "--no-clean", "--local-commit"]
+ if self._options.attachment_id:
+ patch_options.insert("apply-attachment")
+ patch_options.append(self._options.attachment_id)
+ elif self._options.bug_id:
+ patch_options.insert("apply-from-bug")
+ patch_options.append(self._options.bug_id)
+ else:
+ _log.info("Exporting local changes")
+ return
+ raise TypeError("Retrieval of patch from bugzilla is not yet implemented")
+
+ def clean(self):
+ _log.info('Cleaning web-platform-tests master branch')
+ self._git.checkout('master')
+ self._git.reset_hard('origin/master')
+
+ def create_branch_with_patch(self, patch):
+ _log.info('Applying patch to web-platform-tests branch ' + self._branch_name)
+ try:
+ self._git.checkout_new_branch(self._branch_name)
+ except Exception as e:
+ _log.warning(e)
+ _log.info("Retrying to create the branch")
+ self._git.delete_branch(self._branch_name)
+ self._git.checkout_new_branch(self._branch_name)
+ try:
+ self._git.apply_mail_patch([patch, '--exclude', '*-expected.txt'])
+ except Exception as e:
+ _log.warning(e)
+ self._git.apply_mail_patch(['--abort'])
+ return False
+ self._git.commit(['-a', '-m', self._commit_message])
+ return True
+
+ def push_to_wpt_fork(self):
+ self.create_upload_remote_if_needed()
+ wpt_fork_branch_github_url = "https://github.com/" + self._username + "/web-platform-tests/tree/" + self._public_branch_name
+ _log.info('Pushing branch ' + self._branch_name + " to " + self._git.remote(["get-url", self._wpt_fork_remote]).rstrip())
+ _log.info('This may take some time')
+ self._git.push([self._wpt_fork_remote, self._branch_name + ":" + self._public_branch_name, '-f'])
+ _log.info('Branch available at ' + wpt_fork_branch_github_url)
+ return True
+
+ def make_pull_request(self):
+ if not self._github:
+ _log.info('Missing information to create a pull request')
+ return
+
+ _log.info('Making pull request')
+ description = self._bugzilla.fetch_bug_dictionary(self._bug_id)["title"]
+ pr_number = self._github.create_pr(self._wpt_fork_remote + ':' + self._branch_name, self._commit_message, description)
+ if self._bug_id:
+ self._bugzilla.post_comment_to_bug(self._bug_id, "Submitted web-platform-tests pull request: " + WPT_PR_URL + str(pr_number))
+
+ def delete_local_branch(self):
+ _log.info('Removing branch ' + self._branch_name)
+ self._git.checkout('master')
+ self._git.delete_branch(self._branch_name)
+
+ def create_git_patch(self):
+ patch_file = './patch.temp.' + str(time.clock())
+ git_commit = "HEAD...." if not self._options.git_commit else self._options.git_commit + "~1.." + self._options.git_commit
+ patch_data = self._host.scm().create_patch(git_commit, [WEBKIT_WPT_DIR])
+ if not patch_data or not 'diff' in patch_data:
+ _log.info('No changes to upstream, patch data is: "%s"' % (patch_data))
+ return ''
+ # FIXME: We can probably try to use --relative git parameter to not do that replacement.
+ patch_data = patch_data.replace(WEBKIT_WPT_DIR + '/', '')
+ patch_file = self._filesystem.abspath(patch_file)
+ self._filesystem.write_text_file(patch_file, patch_data)
+ return patch_file
+
+ def create_upload_remote_if_needed(self):
+ if not self._wpt_fork_remote in self._git.remote([]):
+ self._git.remote(["add", self._wpt_fork_remote, self._wpt_fork_push_url])
+
+ def do_export(self):
+ git_patch_file = self.create_git_patch()
+
+ if not git_patch_file:
+ _log.error("Unable to create a patch to apply to web-platform-tests repository")
+ return
+
+ self._fetch_wpt_repository()
+ self.clean()
+
+ if not self.create_branch_with_patch(git_patch_file):
+ _log.error("Cannot create web-platform-tests local branch from the patch")
+ self.delete_local_branch()
+ return
+
+ if git_patch_file:
+ self._filesystem.remove(git_patch_file)
+
+ try:
+ if self.push_to_wpt_fork():
+ if self._options.create_pull_request:
+ self.make_pull_request()
+ finally:
+ self.delete_local_branch()
+ _log.info("Finished")
+ self.clean()
+
+
+def parse_args(args):
+ description = """Script to generate a pull request to W3C web-platform-tests repository
+ 'Tools/Scripts/export-w3c-test-changes -c -g HEAD -b XYZ' will do the following:
+ - Clone web-platform-tests repository if not done already and set it up for pushing branches.
+ - Gather WebKit bug id XYZ bug and changes to apply to web-platform-tests repository based on the HEAD commit
+ - Create a remote branch named webkit-XYZ on https://github.com/USERNAME/web-platform-tests.git repository based on the locally applied patch.
+ - USERNAME may be set using the environment variable GITHUB_USERNAME or as a command line option. It is then stored in git config as github.username.
+ - Github credential may be set using the environment variable GITHUB_TOKEN or as a command line option. (Please provide a valid GitHub 'Personal access token' with 'repo' as scope). It is then stored in git config as github.token.
+ - Make the related pull request on https://github.com/w3c/web-platform-tests.git repository.
+ - Clean the local Git repository
+ Notes:
+ - It is safer to provide a bug id using -b option (bug id from a git commit is not always working).
+ - As a dry run, one can start by running the script without -c. This will only create the branch on the user public GitHub repository.
+ - By default, the script will create an https remote URL that will require a password-based authentication to GitHub. If you are using an SSH key, please use the --remote-url option.
+ FIXME:
+ - Add a label on github issues
+ - The script is not yet able to update an existing pull request
+ - Need a way to monitor the progress of the pul request so that status of all pending pull requests can be done at import time.
+ """
+ parser = argparse.ArgumentParser(prog='export-w3c-test-changes ...', description=description, formatter_class=argparse.RawDescriptionHelpFormatter)
+
+ parser.add_argument('-g', '--git-commit', dest='git_commit', default=None, help='Git commit to apply')
+ parser.add_argument('-b', '--bug', dest='bug_id', default=None, help='Bug ID to search for patch')
+ parser.add_argument('-a', '--attachment', dest='attachment_id', default=None, help='Attachment ID to search for patch')
+ parser.add_argument('-n', '--name', dest='username', default=None, help='github user name if GITHUB_USERNAME is not defined or github.username in the WPT repo config is not defined')
+ parser.add_argument('-t', '--token', dest='token', default=None, help='github token, needed for creating pull requests only if GITHUB_TOKEN env variable is not defined or github.token in the WPT repo config is not defined')
+ parser.add_argument('-bn', '--branch-name', dest='public_branch_name', default=None, help='Branch name to push to')
+ parser.add_argument('-m', '--message', dest='message', default=None, help='Commit message')
+ parser.add_argument('-r', '--remote', dest='repository_remote', default=None, help='repository origin to use to push')
+ parser.add_argument('-u', '--remote-url', dest='repository_remote_url', default=None, help='repository url to use to push')
+ parser.add_argument('-d', '--repository', dest='repository_directory', default=None, help='repository directory')
+ parser.add_argument('-c', '--create-pr', dest='create_pull_request', action='', default=False, help='create pull request to w3c web-platform-tests')
+
+ options, args = parser.parse_known_args(args)
+
+ return options
+
+
+def configure_logging():
+ class LogHandler(logging.StreamHandler):
+
+ def format(self, record):
+ if record.levelno > logging.INFO:
+ return "%s: %s" % (record.levelname, record.getMessage())
+ return record.getMessage()
+
+ logger = logging.getLogger()
+ logger.setLevel(logging.INFO)
+ handler = LogHandler()
+ handler.setLevel(logging.INFO)
+ logger.addHandler(handler)
+ return handler
+
+
+def main(_argv, _stdout, _stderr):
+ options = parse_args(_argv)
+
+ configure_logging()
+
+ test_exporter = TestExporter(Host(), options)
+
+ test_exporter.do_export()
Added: trunk/Tools/Scripts/webkitpy/w3c/test_exporter_unittest.py (0 => 225737)
--- trunk/Tools/Scripts/webkitpy/w3c/test_exporter_unittest.py (rev 0)
+++ trunk/Tools/Scripts/webkitpy/w3c/test_exporter_unittest.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -0,0 +1,132 @@
+# Copyright (c) 2017, 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:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS 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 APPLE INC. AND ITS 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 webkitpy.common.host_mock import MockHost
+from webkitpy.common.system.filesystem_mock import MockFileSystem
+from webkitpy.common.system.executive_mock import MockExecutive2
+from webkitpy.w3c.test_exporter import TestExporter, parse_args
+from webkitpy.w3c.wpt_github_mock import MockWPTGitHub
+
+
+class TestExporterTest(unittest.TestCase):
+ maxDiff = None
+
+ class MockBugzilla(object):
+ def __init__(self):
+ self.calls = []
+
+ def fetch_bug_dictionary(self, id):
+ self.calls.append('fetch bug ' + id)
+ return {"title": "my bug title"}
+
+ def post_comment_to_bug(self, id, comment):
+ self.calls.append('post comment to bug ' + id + ' : ' + comment)
+ return True
+
+ class MockGit(object):
+ @classmethod
+ def clone(cls, url, directory, executive=None):
+ return True
+
+ def __init__(self, repository_directory, patch_directories, executive, filesystem):
+ self.calls = [repository_directory]
+
+ def fetch(self):
+ self.calls.append('fetch')
+
+ def checkout(self, branch):
+ self.calls.append('checkout ' + branch)
+
+ def reset_hard(self, commit):
+ self.calls.append('reset hard ' + commit)
+
+ def push(self, options):
+ self.calls.append('push ' + ' '.join(options))
+
+ def format_patch(self, options):
+ self.calls.append('format patch ' + ' '.join(options))
+ return 'formatted patch with changes done to LayoutTests/imported/w3c/web-platform-tests/test1.html'
+
+ def delete_branch(self, branch_name):
+ self.calls.append('delete branch ' + branch_name)
+
+ def checkout_new_branch(self, branch_name):
+ self.calls.append('checkout new branch ' + branch_name)
+
+ def apply_mail_patch(self, options):
+ # filtering options[0] as it changes for every run
+ self.calls.append('apply_mail_patch patch.temp ' + ' '.join(options[1:]))
+
+ def commit(self, options):
+ self.calls.append('commit ' + ' '.join(options))
+
+ def remote(self, options):
+ self.calls.append('remote ' + ' '.join(options))
+ return "my_remote_url"
+
+ def local_config(self, name):
+ return 'value'
+
+ def branch_ref_exists(self, name):
+ return False
+
+ def create_patch(self, commit, arguments):
+ self.calls.append('create_patch ' + commit + ' ' + str(arguments))
+ return 'my patch containing some diffs'
+
+ class MyMockHost(MockHost):
+ def __init__(self):
+ MockHost.__init__(self)
+ self.executive = MockExecutive2(exception=OSError())
+ self.filesystem = MockFileSystem()
+ self._mockSCM = TestExporterTest.MockGit(None, None, None, None)
+
+ def scm(self):
+ return self._mockSCM
+
+ def test_export(self):
+ host = TestExporterTest.MyMockHost()
+ options = parse_args(['test_exporter.py', '-g', 'HEAD', '-b', '1234', '-c', '-n', 'USER', '-t', 'TOKEN'])
+ exporter = TestExporter(host, options, TestExporterTest.MockGit, TestExporterTest.MockBugzilla, MockWPTGitHub)
+ exporter.do_export()
+ self.assertEquals(exporter._github.calls, ['create_pr'])
+ self.assertEquals(exporter._git.calls, [
+ '/mock-checkout/WebKitBuild/w3c-tests/web-platform-tests',
+ 'fetch',
+ 'checkout master',
+ 'reset hard origin/master',
+ 'checkout new branch wpt-export-for-webkit-1234',
+ 'apply_mail_patch patch.temp --exclude *-expected.txt',
+ 'commit -a -m WebKit export of https://bugs.webkit.org/show_bug.cgi?id=1234',
+ 'remote ',
+ 'remote add USER https://[email protected]/USER/web-platform-tests.git',
+ 'remote get-url USER',
+ 'push USER wpt-export-for-webkit-1234:wpt-export-for-webkit-1234 -f',
+ 'checkout master',
+ 'delete branch wpt-export-for-webkit-1234',
+ 'checkout master',
+ 'reset hard origin/master'])
+ self.assertEquals(exporter._bugzilla.calls, [
+ 'fetch bug 1234',
+ 'post comment to bug 1234 : Submitted web-platform-tests pull request: https://github.com/w3c/web-platform-tests/pull/5678'])
Added: trunk/Tools/Scripts/webkitpy/w3c/wpt_github.py (0 => 225737)
--- trunk/Tools/Scripts/webkitpy/w3c/wpt_github.py (rev 0)
+++ trunk/Tools/Scripts/webkitpy/w3c/wpt_github.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -0,0 +1,427 @@
+# Copyright 2016 The Chromium Authors. 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 base64
+import json
+import logging
+import re
+import urllib2
+from collections import namedtuple
+
+from webkitpy.common.memoized import memoized
+from webkitpy.w3c.common import WPT_GH_ORG, WPT_GH_REPO_NAME, EXPORT_PR_LABEL
+
+_log = logging.getLogger(__name__)
+API_BASE = 'https://api.github.com'
+MAX_PER_PAGE = 100
+
+
+class WPTGitHub(object):
+ """An interface to GitHub for interacting with the web-platform-tests repo.
+
+ This class contains methods for sending requests to the GitHub API.
+ Unless mentioned otherwise, API calls are expected to succeed, and
+ GitHubError will be raised if an API call fails.
+ """
+
+ def __init__(self, host, user=None, token=None, pr_history_window=5000):
+ self.host = host
+ self.user = user
+ self.token = token
+
+ self._pr_history_window = pr_history_window
+
+ def has_credentials(self):
+ return self.user and self.token
+
+ def auth_token(self):
+ assert self.has_credentials()
+ return base64.b64encode('{}:{}'.format(self.user, self.token))
+
+ def request(self, path, method, body=None):
+ """Sends a request to GitHub API and deserializes the response.
+
+ Args:
+ path: API endpoint without base URL (starting with '/').
+ method: HTTP method to be used for this request.
+ body: Optional payload in the request body (default=None).
+
+ Returns:
+ A JSONResponse instance.
+ """
+ assert path.startswith('/')
+
+ if body:
+ body = json.dumps(body)
+
+ headers = {'Accept': 'application/vnd.github.v3+json'}
+
+ if self.has_credentials():
+ headers['Authorization'] = 'Basic {}'.format(self.auth_token())
+
+ response = self.host.web.request(
+ method=method,
+ url="" + path,
+ data=""
+ headers=headers
+ )
+ return JSONResponse(response)
+
+ @staticmethod
+ def extract_link_next(link_header):
+ """Extracts the URI to the next page of results from a response.
+
+ As per GitHub API specs, the link to the next page of results is
+ extracted from the Link header -- the link with relation type "next".
+ Docs: https://developer.github.com/v3/#pagination (and RFC 5988)
+
+ Args:
+ link_header: The value of the Link header in responses from GitHub.
+
+ Returns:
+ Path to the next page (without base URL), or None if not found.
+ """
+ # TODO(robertma): Investigate "may require expansion as URI templates" mentioned in docs.
+ # Example Link header:
+ # <https://api.github.com/resources?page=3>; rel="next", <https://api.github.com/resources?page=50>; rel="last"
+ if link_header is None:
+ return None
+ link_re = re.compile(r'<(.+?)>; *rel="(.+?)"')
+ match = link_re.search(link_header)
+ while match:
+ link, rel = match.groups()
+ if rel.lower() == 'next':
+ # Strip API_BASE so that the return value is useful for request().
+ assert link.startswith(API_BASE)
+ return link[len(API_BASE):]
+ match = link_re.search(link_header, match.end())
+ return None
+
+ def create_pr(self, remote_branch_name, desc_title, body):
+ """Creates a PR on GitHub.
+
+ API doc: https://developer.github.com/v3/pulls/#create-a-pull-request
+
+ Returns:
+ The issue number of the created PR.
+ """
+ assert remote_branch_name
+ assert desc_title
+ assert body
+
+ path = '/repos/%s/%s/pulls' % (WPT_GH_ORG, WPT_GH_REPO_NAME)
+ body = {
+ 'title': desc_title,
+ 'body': body,
+ 'head': remote_branch_name,
+ 'base': 'master',
+ }
+ response = self.request(path, method='POST', body=body)
+
+ if response.status_code != 201:
+ raise GitHubError(201, response.status_code, 'create PR')
+
+ return response.data['number']
+
+ def update_pr(self, pr_number, desc_title, body):
+ """Updates a PR on GitHub.
+
+ API doc: https://developer.github.com/v3/pulls/#update-a-pull-request
+ """
+ path = '/repos/{}/{}/pulls/{}'.format(
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ pr_number
+ )
+ body = {
+ 'title': desc_title,
+ 'body': body,
+ }
+ response = self.request(path, method='PATCH', body=body)
+
+ if response.status_code != 200:
+ raise GitHubError(200, response.status_code, 'update PR %d' % pr_number)
+
+ def add_label(self, number, label):
+ """Adds a label to a GitHub issue (or PR).
+
+ API doc: https://developer.github.com/v3/issues/labels/#add-labels-to-an-issue
+ """
+ path = '/repos/%s/%s/issues/%d/labels' % (
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ number
+ )
+ body = [label]
+ response = self.request(path, method='POST', body=body)
+
+ if response.status_code != 200:
+ raise GitHubError(200, response.status_code, 'add label %s to issue %d' % (label, number))
+
+ def remove_label(self, number, label):
+ """Removes a label from a GitHub issue (or PR).
+
+ API doc: https://developer.github.com/v3/issues/labels/#remove-a-label-from-an-issue
+ """
+ path = '/repos/%s/%s/issues/%d/labels/%s' % (
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ number,
+ urllib2.quote(label),
+ )
+ response = self.request(path, method='DELETE')
+
+ # The GitHub API documentation claims that this endpoint returns a 204
+ # on success. However in reality it returns a 200.
+ if response.status_code not in (200, 204):
+ raise GitHubError((200, 204), response.status_code, 'remove label %s from issue %d' % (label, number))
+
+ def make_pr_from_item(self, item):
+ labels = [label['name'] for label in item['labels']]
+ return PullRequest(
+ title=item['title'],
+ number=item['number'],
+ body=item['body'],
+ state=item['state'],
+ labels=labels)
+
+ @memoized
+ def all_pull_requests(self):
+ """Fetches all (open and closed) PRs with the export label.
+
+ The maximum number of PRs is pr_history_window. Search endpoint is used
+ instead of listing PRs, because we need to filter by labels.
+ API doc: https://developer.github.com/v3/search/#search-issues
+
+ Returns:
+ A list of PullRequest namedtuples.
+ """
+ path = (
+ '/search/issues'
+ '?q=repo:{}/{}%20type:pr%20label:{}'
+ '&page=1'
+ '&per_page={}'
+ ).format(
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ EXPORT_PR_LABEL,
+ min(MAX_PER_PAGE, self._pr_history_window)
+ )
+ all_prs = []
+ while path is not None and len(all_prs) < self._pr_history_window:
+ response = self.request(path, method='GET')
+ if response.status_code == 200:
+ if response.data['incomplete_results']:
+ raise GitHubError('complete results', 'incomplete results', 'fetch all pull requests', path)
+
+ prs = [self.make_pr_from_item(item) for item in response.data['items']]
+ all_prs += prs[:self._pr_history_window - len(all_prs)]
+ else:
+ raise GitHubError(200, response.status_code, 'fetch all pull requests', path)
+ path = self.extract_link_next(response.getheader('Link'))
+ return all_prs
+
+ def get_pr_branch(self, pr_number):
+ """Gets the remote branch name of a PR.
+
+ API doc: https://developer.github.com/v3/pulls/#get-a-single-pull-request
+
+ Returns:
+ The remote branch name.
+ """
+ path = '/repos/{}/{}/pulls/{}'.format(
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ pr_number
+ )
+ response = self.request(path, method='GET')
+
+ if response.status_code != 200:
+ raise GitHubError(200, response.status_code, 'get the branch of PR %d' % pr_number)
+
+ return response.data['head']['ref']
+
+ def is_pr_merged(self, pr_number):
+ """Checks if a PR has been merged.
+
+ API doc: https://developer.github.com/v3/pulls/#get-if-a-pull-request-has-been-merged
+
+ Returns:
+ True if merged, False if not.
+ """
+ path = '/repos/%s/%s/pulls/%d/merge' % (
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ pr_number
+ )
+ try:
+ response = self.request(path, method='GET')
+ if response.status_code == 204:
+ return True
+ else:
+ raise GitHubError(204, response.status_code, 'check if PR %d is merged' % pr_number)
+ except urllib2.HTTPError as e:
+ if e.code == 404:
+ return False
+ else:
+ raise
+
+ def merge_pr(self, pr_number):
+ """Merges a PR.
+
+ If merge cannot be performed, MergeError is raised. GitHubError is
+ raised when other unknown errors happen.
+
+ API doc: https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button
+ """
+ path = '/repos/%s/%s/pulls/%d/merge' % (
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ pr_number
+ )
+ body = {
+ 'merge_method': 'rebase',
+ }
+
+ try:
+ response = self.request(path, method='PUT', body=body)
+ except urllib2.HTTPError as e:
+ if e.code == 405:
+ raise MergeError(pr_number)
+ else:
+ raise
+
+ if response.status_code != 200:
+ raise GitHubError(200, response.status_code, 'merge PR %d' % pr_number)
+
+ def delete_remote_branch(self, remote_branch_name):
+ """Deletes a remote branch.
+
+ API doc: https://developer.github.com/v3/git/refs/#delete-a-reference
+ """
+ path = '/repos/%s/%s/git/refs/heads/%s' % (
+ WPT_GH_ORG,
+ WPT_GH_REPO_NAME,
+ remote_branch_name
+ )
+ response = self.request(path, method='DELETE')
+
+ if response.status_code != 204:
+ raise GitHubError(204, response.status_code, 'delete remote branch %s' % remote_branch_name)
+
+ def pr_for_chromium_commit(self, chromium_commit):
+ """Returns a PR corresponding to the given ChromiumCommit, or None."""
+ pull_request = self.pr_with_change_id(chromium_commit.change_id())
+ if pull_request:
+ return pull_request
+ # The Change ID can't be used for commits made via Rietveld,
+ # so we fall back to trying to use commit position here.
+ # Note that Gerrit returns ToT+1 as the commit positions for in-flight
+ # CLs, but they are scrubbed from the PR description and hence would
+ # not be mismatched to random Chromium commits in the fallback.
+ # TODO(robertma): Remove this fallback after Rietveld becomes read-only.
+ return self.pr_with_position(chromium_commit.position)
+
+ def pr_with_change_id(self, target_change_id):
+ for pull_request in self.all_pull_requests():
+ # Note: Search all 'Change-Id's so that we can manually put multiple
+ # CLs in one PR. (The exporter always creates one PR for each CL.)
+ change_ids = self.extract_metadata('Change-Id: ', pull_request.body, all_matches=True)
+ if target_change_id in change_ids:
+ return pull_request
+ return None
+
+ def pr_with_position(self, position):
+ for pull_request in self.all_pull_requests():
+ # Same as above, search all 'Cr-Commit-Position's.
+ pr_commit_positions = self.extract_metadata('Cr-Commit-Position: ', pull_request.body, all_matches=True)
+ if position in pr_commit_positions:
+ return pull_request
+ return None
+
+ @staticmethod
+ def extract_metadata(tag, commit_body, all_matches=False):
+ values = []
+ for line in commit_body.splitlines():
+ if not line.startswith(tag):
+ continue
+ value = line[len(tag):]
+ if all_matches:
+ values.append(value)
+ else:
+ return value
+ return values if all_matches else None
+
+
+class JSONResponse(object):
+ """An HTTP response containing JSON data."""
+
+ def __init__(self, raw_response):
+ """Initializes a JSONResponse instance.
+
+ Args:
+ raw_response: a response object returned by open methods in urllib2.
+ """
+ self._raw_response = raw_response
+ self.status_code = raw_response.getcode()
+ try:
+ self.data = ""
+ except ValueError:
+ self.data = ""
+
+ def getheader(self, header):
+ """Gets the value of the header with the given name.
+
+ Delegates to HTTPMessage.getheader(), which is case-insensitive."""
+ return self._raw_response.info().getheader(header)
+
+
+class GitHubError(Exception):
+ """Raised when an GitHub returns a non-OK response status for a request."""
+
+ def __init__(self, expected, received, action, extra_data=None):
+ message = 'Expected {}, but received {} from GitHub when attempting to {}'.format(
+ expected, received, action
+ )
+ if extra_data:
+ message += '\n' + str(extra_data)
+ super(GitHubError, self).__init__(message)
+
+
+class MergeError(GitHubError):
+ """An error specifically for when a PR cannot be merged.
+
+ This should only be thrown when GitHub returns status code 405,
+ indicating that the PR could not be merged.
+ """
+
+ def __init__(self, pr_number):
+ super(MergeError, self).__init__(200, 405, 'merge PR %d' % pr_number)
+
+
+PullRequest = namedtuple('PullRequest', ['title', 'number', 'body', 'state', 'labels'])
Added: trunk/Tools/Scripts/webkitpy/w3c/wpt_github_mock.py (0 => 225737)
--- trunk/Tools/Scripts/webkitpy/w3c/wpt_github_mock.py (rev 0)
+++ trunk/Tools/Scripts/webkitpy/w3c/wpt_github_mock.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -0,0 +1,125 @@
+# Copyright 2017 The Chromium Authors. 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.w3c.wpt_github import MergeError, WPTGitHub
+
+
+class MockWPTGitHub(object):
+
+ # Some unused arguments may be included to match the real class's API.
+ # pylint: disable=unused-argument
+
+ def __init__(self, pull_requests, unsuccessful_merge_index=-1, create_pr_fail_index=-1, merged_index=-1):
+ """Initializes a mock WPTGitHub.
+
+ Args:
+ pull_requests: A list of wpt_github.PullRequest.
+ unsuccessful_merge_index: The index to the PR in pull_requests that
+ cannot be merged. (-1 means all can be merged.)
+ create_pr_fail_index: The 0-based index of which PR creation request
+ will fail. (-1 means all will succeed.)
+ merged_index: The index to the PR in pull_requests that is already
+ merged. (-1 means none is merged.)
+ """
+ self.pull_requests = pull_requests
+ self.calls = []
+ self.pull_requests_created = []
+ self.pull_requests_merged = []
+ self.unsuccessful_merge_index = unsuccessful_merge_index
+ self.create_pr_index = 0
+ self.create_pr_fail_index = create_pr_fail_index
+ self.merged_index = merged_index
+
+ def all_pull_requests(self, limit=30):
+ self.calls.append('all_pull_requests')
+ return self.pull_requests
+
+ def is_pr_merged(self, number):
+ for index, pr in enumerate(self.pull_requests):
+ if pr.number == number:
+ return index == self.merged_index
+ return False
+
+ def merge_pr(self, number):
+ self.calls.append('merge_pr')
+
+ for index, pr in enumerate(self.pull_requests):
+ if pr.number == number and index == self.unsuccessful_merge_index:
+ raise MergeError(number)
+
+ self.pull_requests_merged.append(number)
+
+ def create_pr(self, remote_branch_name, desc_title, body):
+ self.calls.append('create_pr')
+
+ if self.create_pr_fail_index != self.create_pr_index:
+ self.pull_requests_created.append((remote_branch_name, desc_title, body))
+
+ self.create_pr_index += 1
+ return 5678
+
+ def update_pr(self, pr_number, desc_title, body):
+ self.calls.append('update_pr')
+ return 5678
+
+ def delete_remote_branch(self, _):
+ self.calls.append('delete_remote_branch')
+
+ def add_label(self, _, label):
+ self.calls.append('add_label "%s"' % label)
+
+ def remove_label(self, _, label):
+ self.calls.append('remove_label "%s"' % label)
+
+ def get_pr_branch(self, number):
+ self.calls.append('get_pr_branch')
+ return 'fake_branch_PR_%d' % number
+
+ def pr_for_chromium_commit(self, commit):
+ self.calls.append('pr_for_chromium_commit')
+ for pr in self.pull_requests:
+ if commit.change_id() in pr.body:
+ return pr
+ return None
+
+ def pr_with_position(self, position):
+ self.calls.append('pr_with_position')
+ for pr in self.pull_requests:
+ if position in pr.body:
+ return pr
+ return None
+
+ def pr_with_change_id(self, change_id):
+ self.calls.append('pr_with_change_id')
+ for pr in self.pull_requests:
+ if change_id in pr.body:
+ return pr
+ return None
+
+ def extract_metadata(self, tag, commit_body, all_matches=False):
+ return WPTGitHub.extract_metadata(tag, commit_body, all_matches)
Copied: trunk/Tools/Scripts/webkitpy/w3c/wpt_github_unittest.py (from rev 225736, trunk/Tools/Scripts/webkitpy/common/net/web_mock.py) (0 => 225737)
--- trunk/Tools/Scripts/webkitpy/w3c/wpt_github_unittest.py (rev 0)
+++ trunk/Tools/Scripts/webkitpy/w3c/wpt_github_unittest.py 2017-12-11 05:04:33 UTC (rev 225737)
@@ -0,0 +1,48 @@
+# Copyright 2017 The Chromium Authors. 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 base64
+import unittest
+
+from webkitpy.common.host_mock import MockHost
+from webkitpy.w3c.wpt_github import WPTGitHub, MergeError
+
+
+class WPTGitHubTest(unittest.TestCase):
+
+ def setUp(self):
+ self.wpt_github = WPTGitHub(MockHost(), user='rutabaga', token='decafbad')
+
+ def test_init(self):
+ self.assertEqual(self.wpt_github.user, 'rutabaga')
+ self.assertEqual(self.wpt_github.token, 'decafbad')
+
+ def test_auth_token(self):
+ self.assertEqual(
+ self.wpt_github.auth_token(),
+ base64.encodestring('rutabaga:decafbad').strip())