Title: [225737] trunk/Tools
Revision
225737
Author
[email protected]
Date
2017-12-10 21:04:33 -0800 (Sun, 10 Dec 2017)

Log Message

Add a script to automate W3c web-platform-tests pull request creations from WebKit commits
https://bugs.webkit.org/show_bug.cgi?id=169462

Patch by Youenn Fablet <[email protected]> on 2017-12-10
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.

Modified Paths

Added Paths

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())
_______________________________________________
webkit-changes mailing list
[email protected]
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to