Title: [281695] trunk/Tools
Revision
281695
Author
jbed...@apple.com
Date
2021-08-27 08:16:27 -0700 (Fri, 27 Aug 2021)

Log Message

[git-webkit] Add pull-request command (Part 6)
https://bugs.webkit.org/show_bug.cgi?id=229089
<rdar://problem/81908751>

Reviewed by Dewei Zhu.

* Scripts/libraries/webkitscmpy/setup.py: Bump version.
* Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py: Ditto.
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/local/git.py:
(Git.__init__): Add commit, add and push commands.
(Git.commit): Create new commit from staged files.
(Git.add): Stage modified files.
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/bitbucket.py:
(BitBucket.__init__): Add pull_requests.
(BitBucket.request): Add ability to list and edit pull requets.
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/git_hub.py:
(GitHub.__init__): Add pull_requests.
(GitHub.request): Add ability to list and edit pull requets.
* Scripts/libraries/webkitscmpy/webkitscmpy/program/__init__.py: Add PullRequest.
* Scripts/libraries/webkitscmpy/webkitscmpy/program/pull_request.py:
(PullRequest.parser): Add '--add' and '--no-add' to allow user to specify how modified files
are incorperated into the pull-request.
(PullRequest.create_commit): Based on currently modified files, either create a new commit or
add those files to an existing commit.
(PullRequest.branch_point): Determine when this branch diverged from a production branch.
(PullRequest.main): Create branch, create commit on branch, push branch and either create or
update a pull-request.
* Scripts/libraries/webkitscmpy/webkitscmpy/remote/bitbucket.py:
(BitBucket.PRGenerator.find):
(BitBucket.PRGenerator.create):
(BitBucket.PRGenerator.update):
(BitBucket.__init__): Add pull_request generator.
* Scripts/libraries/webkitscmpy/webkitscmpy/remote/git_hub.py:
(GitHub.PRGenerator.find):
(GitHub.PRGenerator.create):
(GitHub.PRGenerator.update):
(GitHub.__init__): Add pull_request generator.
* Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py:
(Scm.PRGenerator.__init__):
(Scm.PRGenerator.find):
(Scm.PRGenerator.create):
(Scm.PRGenerator.update):
* Scripts/libraries/webkitscmpy/webkitscmpy/test/pull_request_unittest.py:
(TestDoPullRequest.setUp):
(TestDoPullRequest.test_svn):
(TestDoPullRequest.test_no_modified):
(TestDoPullRequest.test_staged):
(TestDoPullRequest.test_modified):
(TestDoPullRequest.test_github):
(TestDoPullRequest.test_github_update):
(TestDoPullRequest.test_stash):
(TestDoPullRequest.test_stash_update):

Modified Paths

Added Paths

Diff

Modified: trunk/Tools/ChangeLog (281694 => 281695)


--- trunk/Tools/ChangeLog	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/ChangeLog	2021-08-27 15:16:27 UTC (rev 281695)
@@ -1,5 +1,60 @@
 2021-08-27  Jonathan Bedard  <jbed...@apple.com>
 
+        [git-webkit] Add pull-request command (Part 6)
+        https://bugs.webkit.org/show_bug.cgi?id=229089
+        <rdar://problem/81908751>
+
+        Reviewed by Dewei Zhu.
+
+        * Scripts/libraries/webkitscmpy/setup.py: Bump version.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py: Ditto.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/local/git.py:
+        (Git.__init__): Add commit, add and push commands.
+        (Git.commit): Create new commit from staged files.
+        (Git.add): Stage modified files.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/bitbucket.py:
+        (BitBucket.__init__): Add pull_requests.
+        (BitBucket.request): Add ability to list and edit pull requets.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/git_hub.py:
+        (GitHub.__init__): Add pull_requests.
+        (GitHub.request): Add ability to list and edit pull requets.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/program/__init__.py: Add PullRequest.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/program/pull_request.py:
+        (PullRequest.parser): Add '--add' and '--no-add' to allow user to specify how modified files
+        are incorperated into the pull-request.
+        (PullRequest.create_commit): Based on currently modified files, either create a new commit or
+        add those files to an existing commit.
+        (PullRequest.branch_point): Determine when this branch diverged from a production branch.
+        (PullRequest.main): Create branch, create commit on branch, push branch and either create or
+        update a pull-request.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/remote/bitbucket.py:
+        (BitBucket.PRGenerator.find):
+        (BitBucket.PRGenerator.create):
+        (BitBucket.PRGenerator.update):
+        (BitBucket.__init__): Add pull_request generator.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/remote/git_hub.py:
+        (GitHub.PRGenerator.find):
+        (GitHub.PRGenerator.create):
+        (GitHub.PRGenerator.update):
+        (GitHub.__init__): Add pull_request generator.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py:
+        (Scm.PRGenerator.__init__):
+        (Scm.PRGenerator.find):
+        (Scm.PRGenerator.create):
+        (Scm.PRGenerator.update):
+        * Scripts/libraries/webkitscmpy/webkitscmpy/test/pull_request_unittest.py:
+        (TestDoPullRequest.setUp):
+        (TestDoPullRequest.test_svn):
+        (TestDoPullRequest.test_no_modified):
+        (TestDoPullRequest.test_staged):
+        (TestDoPullRequest.test_modified):
+        (TestDoPullRequest.test_github):
+        (TestDoPullRequest.test_github_update):
+        (TestDoPullRequest.test_stash):
+        (TestDoPullRequest.test_stash_update):
+
+2021-08-27  Jonathan Bedard  <jbed...@apple.com>
+
         [run-webkit-tests] Use Python 3 (Part 3)
         https://bugs.webkit.org/show_bug.cgi?id=226658
         <rdar://problem/78882016>

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/setup.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/setup.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/setup.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -29,7 +29,7 @@
 
 setup(
     name='webkitscmpy',
-    version='1.1.9',
+    version='2.0.0',
     description='Library designed to interact with git and svn repositories.',
     long_description=readme(),
     classifiers=[

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -46,7 +46,7 @@
         "Please install webkitcorepy with `pip install webkitcorepy --extra-index-url <package index URL>`"
     )
 
-version = Version(1, 1, 9)
+version = Version(2, 0, 0)
 
 AutoInstall.register(Package('fasteners', Version(0, 15, 0)))
 AutoInstall.register(Package('monotonic', Version(1, 5)))

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/local/git.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/local/git.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/local/git.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -20,6 +20,7 @@
 # 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 hashlib
 import json
 import os
 import re
@@ -28,7 +29,7 @@
 from datetime import datetime
 from mock import patch
 
-from webkitcorepy import decorators, mocks, OutputCapture, StringIO
+from webkitcorepy import decorators, mocks, string_utils, OutputCapture, StringIO
 from webkitscmpy import local, Commit, Contributor
 from webkitscmpy.program.canonicalize.committer import main as committer_main
 from webkitscmpy.program.canonicalize.message import main as message_main
@@ -412,6 +413,22 @@
                 generator=lambda *args, **kwargs:
                     mocks.ProcessCompletion(returncode=0) if re.match(r'^[A-Za-z0-9-]+/[A-Za-z0-9/-]+$', args[2]) else mocks.ProcessCompletion(),
             ), mocks.Subprocess.Route(
+                self.executable, 'commit',
+                cwd=self.path,
+                generator=lambda *args, **kwargs: self.commit(amend=False),
+            ), mocks.Subprocess.Route(
+                self.executable, 'commit', '--amend',
+                cwd=self.path,
+                generator=lambda *args, **kwargs: self.commit(amend=True),
+            ), mocks.Subprocess.Route(
+                self.executable, 'add', re.compile(r'.+'),
+                cwd=self.path,
+                generator=lambda *args, **kwargs: self.add(args[2]),
+            ), mocks.Subprocess.Route(
+                self.executable, 'push', '-f',
+                cwd=self.path,
+                generator=lambda *args, **kwargs: mocks.ProcessCompletion(returncode=0),
+            ), mocks.Subprocess.Route(
                 self.executable,
                 cwd=self.path,
                 completion=mocks.ProcessCompletion(
@@ -519,6 +536,9 @@
             self.commits[something][-1] = Commit.from_json(Commit.Encoder().default(self.head))
             self.head = self.commits[something][-1]
             self.head.branch = something
+            if not self.head.branch_point:
+                self.head.branch_point = self.head.identifier
+                self.head.identifier = 0
             return True
 
         if commit:
@@ -709,3 +729,35 @@
                 configfile.write('\t{}={}\n'.format(key_b, value))
 
         return mocks.ProcessCompletion(returncode=0)
+
+    def commit(self, amend=False):
+        if not self.head:
+            return mocks.ProcessCompletion(returncode=1, stdout='Allowed in git, but disallowed by reasonable workflows')
+        if not self.staged and not amend:
+            return mocks.ProcessCompletion(returncode=1, stdout='no changes added to commit (use "git add" and/or "git commit -a")\n')
+
+        if not amend:
+            self.head = Commit(
+                branch=self.branch, repository_id=self.head.repository_id,
+                timestamp=int(time.time()),
+                identifier=self.head.identifier + 1 if self.head.branch_point else 1,
+                branch_point=self.head.branch_point or self.head.identifier,
+            )
+            self.commits[self.branch].append(self.head)
+
+        self.head.author = Contributor(self.config()['user.name'], [self.config()['user.email']])
+        self.head.message = '{} commit\nReviewed by Jonathan Bedard\n\n * {}\n'.format(
+            'Amended' if amend else 'Created',
+            '\n * '.join(self.staged.keys()),
+        )
+        self.head.hash = hashlib.sha256(string_utils.encode(self.head.message)).hexdigest()[:40]
+        self.staged = {}
+        return mocks.ProcessCompletion(returncode=0)
+
+    def add(self, file):
+        if file not in self.modified:
+            return mocks.ProcessCompletion(returncode=128, stdout="fatal: pathspec '{}' did not match any files\n".format(file))
+        for key, value in self.modified.items():
+            self.staged[key] = value
+        self.modified = {}
+        return mocks.ProcessCompletion(returncode=0)

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/bitbucket.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/bitbucket.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/bitbucket.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -53,6 +53,7 @@
 
         self.head = self.commits[self.default_branch][-1]
         self.tags = {}
+        self.pull_requests = []
 
     def commit(self, ref):
         if ref in self.commits:
@@ -157,7 +158,7 @@
             ],
         ), url=""
 
-    def request(self, method, url, data="" params=None, **kwargs):
+    def request(self, method, url, data="" params=None, json=None, **kwargs):
         if not url.startswith('http://') and not url.startswith('https://'):
             return mocks.Response.create404(url)
 
@@ -194,4 +195,47 @@
         if stripped_url.startswith('{}/rest/branch-utils/latest/{}/branches/info/'.format(self.hosts[0], self.project)):
             return self._branches_for(stripped_url.split('/')[-1], url, params or {})
 
+        # All pull-requests
+        pr_base = '{}/rest/api/1.0/{}/pull-requests'.format(self.hosts[0], self.project)
+        if method == 'GET' and stripped_url == pr_base:
+            prs = []
+            for candidate in self.pull_requests:
+                states = (params or {}).get('state', [])
+                states = states if isinstance(states, list) else [states]
+                if states and candidate.get('state') not in states:
+                    continue
+                at = (params or {}).get('at', None)
+                if at and candidate.get('fromRef', {}).get('id') != at:
+                    continue
+                prs.append(candidate)
+
+            return mocks.Response.fromJson(dict(
+                size=len(prs),
+                isLastPage=True,
+                values=prs,
+            ))
+
+        # Create pull-request
+        if method == 'POST' and stripped_url == pr_base:
+            json['author'] = dict(user=dict(displayName='Tim Committer', emailAddress='commit...@webkit.org'))
+            json['participants'] = [json['author']]
+            json['id'] = 1 + max([0] + [pr.get('id', 0) for pr in self.pull_requests])
+            json['fromRef']['displayId'] = json['fromRef']['id'].split('/')[-2:]
+            json['toRef']['displayId'] = json['toRef']['id'].split('/')[-2:]
+            self.pull_requests.append(json)
+            return mocks.Response.fromJson(json)
+
+        # Update or access pull-request
+        if stripped_url.startswith(pr_base):
+            number = int(stripped_url.split('/')[-1])
+            existing = None
+            for i in range(len(self.pull_requests)):
+                if self.pull_requests[i].get('id') == number:
+                    existing = i
+            if existing is None:
+                return mocks.Response.create404(url)
+            if method == 'PUT':
+                self.pull_requests[existing].update(json)
+            return mocks.Response.fromJson(self.pull_requests[existing])
+
         return mocks.Response.create404(url)

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/git_hub.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/git_hub.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/git_hub.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -58,6 +58,7 @@
 
         self.head = self.commits[self.default_branch][-1]
         self.tags = {}
+        self.pull_requests = []
         self._environment = None
 
     def __enter__(self):
@@ -280,7 +281,7 @@
             ), url=""
         )
 
-    def request(self, method, url, data="" params=None, **kwargs):
+    def request(self, method, url, data="" params=None, auth=None, json=None, **kwargs):
         if not url.startswith('http://') and not url.startswith('https://'):
             return mocks.Response.create404(url)
 
@@ -331,9 +332,65 @@
 
         # Add fork
         if stripped_url.startswith('{}/forks'.format(self.api_remote)) and method == 'POST':
-            username = kwargs.get('json', {}).get('owner', None)
+            username = (json or {}).get('owner', None)
             if username:
                 self.forks.append(username)
             return mocks.Response.fromJson({}) if username else mocks.Response.create404(url)
 
+        # All pull-requests
+        pr_base = '{}/pulls'.format(self.api_remote)
+        if method == 'GET' and stripped_url == pr_base:
+            prs = []
+            for candidate in self.pull_requests:
+                state = params.get('state', 'all')
+                if state != 'all' and candidate.get('state', 'closed') != state:
+                    continue
+                base = params.get('base')
+                if base and candidate.get('base', {}).get('ref') != base:
+                    continue
+                head = params.get('head')
+                if head and head not in [candidate.get('head', {}).get('ref'), candidate.get('head', {}).get('label')]:
+                    continue
+                prs.append(candidate)
+            return mocks.Response.fromJson(prs)
+
+        # Create/update pull-request
+        pr = dict()
+        if method == 'POST' and auth and stripped_url.startswith(pr_base):
+            if json.get('title'):
+                pr['title'] = json['title']
+            if json.get('body'):
+                pr['body'] = json['body']
+            if json.get('head'):
+                pr['head'] = dict(
+                    label=json['head'],
+                    ref=json['head'].split(':')[-1],
+                    user=dict(login=auth.username),
+                )
+            if json.get('base'):
+                pr['base'] = dict(
+                    label='{}:{}'.format(self.remote.split('/')[-2], json['base']),
+                    ref=json['base'],
+                    user=dict(login=self.remote.split('/')[-2]),
+                )
+
+        # Create specifically
+        if method == 'POST' and auth and stripped_url == pr_base:
+            pr['number'] = 1 + max([0] + [pr.get('number', 0) for pr in self.pull_requests])
+            pr['user'] = dict(login=auth.username)
+            self.pull_requests.append(pr)
+            return mocks.Response.fromJson(pr)
+
+        # Update specifically
+        if method == 'POST' and auth and stripped_url.startswith(pr_base):
+            number = int(stripped_url.split('/')[-1])
+            existing = None
+            for i in range(len(self.pull_requests)):
+                if self.pull_requests[i].get('number') == number:
+                    existing = i
+            if existing is None:
+                return mocks.Response.create404(url)
+            self.pull_requests[existing].update(pr)
+            return mocks.Response.fromJson(self.pull_requests[i])
+
         return mocks.Response.create404(url)

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/__init__.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/__init__.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/__init__.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -34,6 +34,7 @@
 from .find import Find, Info
 from .log import Log
 from .pull import Pull
+from .pull_request import PullRequest
 from .setup_git_svn import SetupGitSvn
 from .setup import Setup
 
@@ -65,8 +66,7 @@
     )
 
     subparsers = parser.add_subparsers(help='sub-command help')
-
-    programs = [Branch, Blame, Canonicalize, Checkout, Clean, Find, Info, Log, Pull, Setup]
+    programs = [Blame, Branch, Canonicalize, Checkout, Clean, Find, Info, Log, Pull, PullRequest, Setup]
     if subversion:
         programs.append(SetupGitSvn)
 

Added: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/pull_request.py (0 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/pull_request.py	                        (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/pull_request.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -0,0 +1,163 @@
+# Copyright (C) 2021 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. OR 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 sys
+
+from .command import Command
+from .branch import Branch
+
+from webkitcorepy import arguments, run, string_utils
+from webkitscmpy import local, log, remote
+
+
+class PullRequest(Command):
+    name = 'pull-request'
+    aliases = ['pr', 'pfr', 'upload']
+    help = 'Push the current checkout state as a pull-request'
+
+    @classmethod
+    def parser(cls, parser, loggers=None):
+        Branch.parser(parser, loggers=loggers)
+        parser.add_argument(
+            '--no-add', '--add',
+            dest='will_add', default=None,
+            help='When drafting a change, add (or never add) modified files to set of staged changes to be committed',
+            action=""
+        )
+
+    @classmethod
+    def create_commit(cls, args, repository, **kwargs):
+        # First, find the set of files to be modified
+        modified = [] if args.will_add is False else repository.modified()
+        if args.will_add:
+            modified = list(set(modified).union(set(repository.modified(staged=False))))
+
+        # Next, add all modified file
+        for file in set(modified) - set(repository.modified(staged=True)):
+            log.warning('    Adding {}...'.format(file))
+            if run([repository.executable(), 'add', file], cwd=repository.root_path).returncode:
+                sys.stderr.write("Failed to add '{}'\n".format(file))
+                return 1
+
+        # Then, see if we already have a commit associated with this branch we need to modify
+        has_commit = repository.commit(include_log=False, include_identifier=False).branch == repository.branch and repository.branch != repository.default_branch
+        if not modified and has_commit:
+            log.warning('Using committed changes...')
+            return 0
+
+        # Otherwise, we need to create a commit
+        if not modified:
+            sys.stderr.write('No modified files\n')
+            return 1
+        log.warning('Amending commit...' if has_commit else 'Creating commit...')
+        if run([repository.executable(), 'commit'] + (['--amend'] if has_commit else []), cwd=repository.root_path).returncode:
+            sys.stderr.write('Failed to generate commit\n')
+            return 1
+
+        return 0
+
+    @classmethod
+    def branch_point(cls, args, repository, **kwargs):
+        cnt = 0
+        commit = None
+        while not commit or commit.branch.startswith(Branch.PREFIX):
+            cnt += 1
+            commit = repository.find(argument='HEAD~{}'.format(cnt), include_log=False, include_identifier=False)
+            log.warning('    Found {}...'.format(string_utils.pluralize(cnt, 'commit')))
+
+        return commit
+
+    @classmethod
+    def main(cls, args, repository, **kwargs):
+        if not isinstance(repository, local.Git):
+            sys.stderr.write("Can only '{}' on a native Git repository\n".format(cls.name))
+            return 1
+
+        if not repository.branch.startswith(Branch.PREFIX):
+            if Branch.main(args, repository, **kwargs):
+                sys.stderr.write("Abandoning pushing pull-request because '{}' could not be created\n".format(args.issue))
+                return 1
+        elif args.issue and repository.branch != args.issue:
+            sys.stderr.write("Creating a pull-request for '{}' but we're on '{}'\n".format(args.issue, repository.branch))
+            return 1
+
+        result = cls.create_commit(args, repository, **kwargs)
+        if result:
+            return result
+
+        branch_point = cls.branch_point(args, repository, **kwargs)
+
+        rmt = repository.remote()
+        if not rmt:
+            sys.stderr.write("'{}' doesn't have a recognized remote\n".format(repository.root_path))
+            return 1
+        target = 'fork' if isinstance(rmt, remote.GitHub) else 'origin'
+        log.warning("Pushing '{}' to '{}'...".format(repository.branch, target))
+        if run([repository.executable(), 'push', '-f', target, repository.branch], cwd=repository.root_path).returncode:
+            sys.stderr.write("Failed to push '{}' to '{}'\n".format(repository.branch, target))
+            return 1
+
+        if not rmt.pull_requests:
+            sys.stderr.write("'{}' cannot generate pull-requests\n".format(rmt.url))
+            return 1
+        user, _ = rmt.credentials(required=True) if isinstance(rmt, remote.GitHub) else (repository.config()['user.email'], None)
+        candidates = list(rmt.pull_requests.find(head=repository.branch))
+        commits = list(repository.commits(begin=dict(hash=branch_point.hash), end=dict(branch=repository.branch)))
+
+        title = commits[0].message.splitlines()[0]
+        for commit in commits[1:]:
+            title_candidate = commit.message.splitlines()[0]
+            while title and not title_candidate.startswith(title):
+                title = title[:-1]
+        if not title:
+            title = commits[0].message.splitlines()[0]
+        title = title.rstrip().lstrip()
+        if title.endswith('(Part'):
+            title = title[:-5].rstrip()
+
+        if candidates:
+            log.warning("Updating pull-request for '{}'...".format(repository.branch))
+            pr = rmt.pull_requests.update(
+                pull_request=candidates[0],
+                title=title,
+                commits=commits,
+                base=branch_point.branch,
+                head=repository.branch,
+            )
+            if not pr:
+                sys.stderr.write("Failed to update pull-request '{}'\n".format(candidates[0]))
+                return 1
+            log.warning("Updated '{}'!".format(pr))
+        else:
+            log.warning("Creating pull-request for '{}'...".format(repository.branch))
+            pr = rmt.pull_requests.create(
+                title=title,
+                commits=commits,
+                base=branch_point.branch,
+                head=repository.branch,
+            )
+            if not pr:
+                sys.stderr.write("Failed to create pull-request for '{}'\n".format(repository.branch))
+                return 1
+            log.warning("Created '{}'!".format(pr))
+
+        return 0

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/bitbucket.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/bitbucket.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/bitbucket.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -25,8 +25,10 @@
 import six
 import sys
 
+import json
+
 from webkitcorepy import decorators
-from webkitscmpy import Commit
+from webkitscmpy import Commit, PullRequest
 from webkitscmpy.remote.scm import Scm
 
 
@@ -33,6 +35,150 @@
 class BitBucket(Scm):
     URL_RE = re.compile(r'\Ahttps?://(?P<domain>\S+)/projects/(?P<project>\S+)/repos/(?P<repository>\S+)\Z')
 
+    class PRGenerator(Scm.PRGenerator):
+        TITLE_CHAR_LIMIT = 254
+        BODY_CHAR_LIMIT = 32766
+
+        def find(self, state=None, head=None, base=None):
+            params = dict(
+                limit=100,
+                withProperties='false',
+                withAttributes='false',
+            )
+            if state == PullRequest.State.OPENED:
+                params['state'] = 'OPEN'
+            if state == PullRequest.State.CLOSED:
+                params['state'] = ['DECLINED', 'MERGED', 'SUPERSEDED']
+            if head:
+                params['direction'] = 'OUTGOING'
+                params['at'] = 'refs/heads/{}'.format(head)
+            data = "" params=params)
+            for datum in data or []:
+                if base and not datum['toRef']['id'].endswith(base):
+                    continue
+                yield PullRequest(
+                    number=datum['id'],
+                    title=datum.get('title'),
+                    body=datum.get('description'),
+                    author=self.repository.contributors.create(
+                        datum['author']['user']['displayName'],
+                        datum['author']['user']['emailAddress'],
+                    ), head=datum['fromRef']['displayId'],
+                    base=datum['toRef']['displayId'],
+                )
+
+        def create(self, head, title, body=None, commits=None, base=None):
+            for key, value in dict(head=head, title=title).items():
+                if not value:
+                    raise ValueError("Must define '{}' when creating pull-request".format(key))
+
+            if len(title) > self.TITLE_CHAR_LIMIT:
+                raise ValueError('Title length too long. Limit is: {}'.format(self.TITLE_CHAR_LIMIT))
+            description = PullRequest.create_body(body, commits)
+            if description and len(description) > self.BODY_CHAR_LIMIT:
+                raise ValueError('Body length too long. Limit is: {}'.format(self.BODY_CHAR_LIMIT))
+            response = requests.post(
+                'https://{domain}/rest/api/1.0/projects/{project}/repos/{name}/pull-requests'.format(
+                    domain=self.repository.domain,
+                    project=self.repository.project,
+                    name=self.repository.name,
+                ), json=dict(
+                    title=title,
+                    description=PullRequest.create_body(body, commits),
+                    fromRef=dict(
+                        id='refs/heads/{}'.format(head),
+                        repository=dict(
+                            slug=self.repository.name,
+                            project=dict(key=self.repository.project),
+                        ),
+                    ), toRef=dict(
+                        id='refs/heads/{}'.format(base or self.repository.default_branch),
+                        repository=dict(
+                            slug=self.repository.name,
+                            project=dict(key=self.repository.project),
+                        ),
+                    ),
+                ),
+            )
+            if response.status_code // 100 != 2:
+                return None
+            data = ""
+            return PullRequest(
+                number=data['id'],
+                title=data.get('title'),
+                body=data.get('description'),
+                author=self.repository.contributors.create(
+                    data['author']['user']['displayName'],
+                    data['author']['user']['emailAddress'],
+                ), head=data['fromRef']['displayId'],
+                base=data['toRef']['displayId'],
+            )
+
+        def update(self, pull_request, head=None, title=None, body=None, commits=None, base=None):
+            if not isinstance(pull_request, PullRequest):
+                raise ValueError(
+                    "Expected 'pull_request' to be of type '{}' not '{}'".format(PullRequest, type(pull_request)))
+            if not any((head, title, body, commits, base)):
+                raise ValueError('No arguments to update pull-request provided')
+
+            to_change = dict()
+            if title:
+                to_change['title'] = title
+            if body or commits:
+                to_change['description'] = PullRequest.create_body(body, commits)
+            if head:
+                to_change['fromRef'] = dict(
+                    id='refs/heads/{}'.format(head),
+                    repository=dict(
+                        slug=self.repository.name,
+                        project=dict(key=self.repository.project),
+                    ),
+                )
+            if commits:
+                if to_change.get('fromRef'):
+                    to_change['fromRef']['latestCommit'] = commits[0].hash
+                else:
+                    to_change['fromRef'] = dict(latestCommit=commits[0].hash)
+            if base:
+                to_change['toRef'] = dict(
+                    id='refs/heads/{}'.format(base or self.repository.default_branch),
+                    repository=dict(
+                        slug=self.repository.name,
+                        project=dict(key=self.repository.project),
+                    ),
+                )
+
+            pr_url = 'https://{domain}/rest/api/1.0/projects/{project}/repos/{name}/pull-requests/{id}'.format(
+                domain=self.repository.domain,
+                project=self.repository.project,
+                name=self.repository.name,
+                id=pull_request.number,
+            )
+            response = requests.get(pr_url)
+            if response.status_code // 100 != 2:
+                return None
+            data = ""
+            del data['author']
+            del data['participants']
+            data.update(to_change)
+
+            response = requests.put(pr_url, json=data)
+            if response.status_code // 100 != 2:
+                return None
+            data = ""
+
+            pull_request.title = data.get('title', pull_request.title)
+            if data.get('description'):
+                pull_request.body, pull_request.commits = pull_request.parse_body(data.get('description'))
+            user = data.get('author', {}).get('user', {})
+            if user.get('displayName') and user.get('emailAddress'):
+                pull_request.author = self.repository.contributors.create(user['displayName'], user['emailAddress'])
+            pull_request.head = data.get('fromRef', {}).get('displayId', pull_request.base)
+            pull_request.base = data.get('toRef', {}).get('displayId', pull_request.base)
+
+            return pull_request
+
+
     @classmethod
     def is_webserver(cls, url):
         return True if cls.URL_RE.match(url) else False
@@ -52,6 +198,8 @@
             id=id or self.name.lower(),
         )
 
+        self.pull_requests = self.PRGenerator(self)
+
     @property
     def is_git(self):
         return True

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/git_hub.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/git_hub.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/git_hub.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -29,7 +29,7 @@
 from datetime import datetime
 from requests.auth import HTTPBasicAuth
 from webkitcorepy import credentials, decorators
-from webkitscmpy import Commit
+from webkitscmpy import Commit, PullRequest
 from webkitscmpy.remote.scm import Scm
 from xml.dom import minidom
 
@@ -38,6 +38,101 @@
     URL_RE = re.compile(r'\Ahttps?://github.(?P<domain>\S+)/(?P<owner>\S+)/(?P<repository>\S+)\Z')
     EMAIL_RE = re.compile(r'(?P<email>[^@]+@[^@]+)(@.*)?')
 
+    class PRGenerator(Scm.PRGenerator):
+        def find(self, state=None, head=None, base=None):
+            if not state:
+                state = 'all'
+            user, _ = self.repository.credentials()
+            data = "" params=dict(
+                state=state,
+                base=base,
+                head='{}:{}'.format(user, head) if user and head else head,
+            ))
+            for datum in data or []:
+                if base and datum['base']['ref'] != base:
+                    continue
+                if head and not datum['head']['ref'].endswith(head):
+                    continue
+                yield PullRequest(
+                    number=datum['number'],
+                    title=datum.get('title'),
+                    body=datum.get('body'),
+                    author=self.repository.contributors.create(datum['user']['login']),
+                    head=datum['head']['ref'],
+                    base=datum['base']['ref'],
+                )
+
+        def create(self, head, title, body=None, commits=None, base=None):
+            for key, value in dict(head=head, title=title).items():
+                if not value:
+                    raise ValueError("Must define '{}' when creating pull-request".format(key))
+
+            user, _ = self.repository.credentials(required=True)
+            response = requests.post(
+                '{api_url}/repos/{owner}/{name}/pulls'.format(
+                    api_url=self.repository.api_url,
+                    owner=self.repository.owner,
+                    name=self.repository.name,
+                ), auth=HTTPBasicAuth(*self.repository.credentials(required=True)),
+                headers=dict(Accept='application/vnd.github.v3+json'),
+                json=dict(
+                    title=title,
+                    body=PullRequest.create_body(body, commits),
+                    base=base or self.repository.default_branch,
+                    head='{}:{}'.format(user, head),
+                ),
+            )
+            if response.status_code // 100 != 2:
+                return None
+            data = ""
+            return PullRequest(
+                number=data['number'],
+                title=data.get('title'),
+                body=data.get('body'),
+                author=self.repository.contributors.create(data['user']['login']),
+                head=data['head']['ref'],
+                base=data['base']['ref'],
+            )
+
+        def update(self, pull_request, head=None, title=None, body=None, commits=None, base=None):
+            if not isinstance(pull_request, PullRequest):
+                raise ValueError("Expected 'pull_request' to be of type '{}' not '{}'".format(PullRequest, type(pull_request)))
+            if not any((head, title, body, commits, base)):
+                raise ValueError('No arguments to update pull-request provided')
+
+            user, _ = self.repository.credentials(required=True)
+            updates = dict(
+                title=title or pull_request.title,
+                base=base or pull_request.base,
+                head='{}:{}'.format(user, head) if head else pull_request.head,
+            )
+            if body or commits:
+                updates['body'] = PullRequest.create_body(body, commits)
+            response = requests.post(
+                '{api_url}/repos/{owner}/{name}/pulls/{number}'.format(
+                    api_url=self.repository.api_url,
+                    owner=self.repository.owner,
+                    name=self.repository.name,
+                    number=pull_request.number,
+                ), auth=HTTPBasicAuth(*self.repository.credentials(required=True)),
+                headers=dict(Accept='application/vnd.github.v3+json'),
+                json=updates,
+            )
+            if response.status_code // 100 != 2:
+                return None
+            data = ""
+
+            pull_request.title = data.get('title', pull_request.title)
+            if data.get('body'):
+                pull_request.body, pull_request.commits = pull_request.parse_body(data.get('body'))
+            if data.get('user', {}).get('login'):
+                pull_request.author = self.repository.contributors.create(data['user']['login'])
+            pull_request.head = data.get('head', {}).get('displayId', pull_request.base)
+            pull_request.base = data.get('base', {}).get('displayId', pull_request.base)
+
+            return pull_request
+
+
     @classmethod
     def is_webserver(cls, url):
         return True if cls.URL_RE.match(url) else False
@@ -62,6 +157,8 @@
             id=id or self.name.lower(),
         )
 
+        self.pull_requests = self.PRGenerator(self)
+
     def credentials(self, required=True):
         username, token = credentials(
             url=""

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -26,6 +26,20 @@
 
 
 class Scm(ScmBase):
+    class PRGenerator(object):
+        def __init__(self, repository):
+            self.repository = repository
+
+        def find(self, state=None, head=None, base=None):
+            raise NotImplementedError()
+
+        def create(self, head, title, body=None, commits=None, base=None):
+            raise NotImplementedError()
+
+        def update(self, pull_request, head=None, title=None, body=None, commits=None, base=None):
+            raise NotImplementedError()
+
+
     @classmethod
     def from_url(cls, url, contributors=None):
         from webkitscmpy import remote
@@ -42,3 +56,4 @@
         if not isinstance(url, six.string_types):
             raise ValueError("Expected 'url' to be a string type, not '{}'".format(type(url)))
         self.url = ""
+        self.pull_requests = None

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/pull_request_unittest.py (281694 => 281695)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/pull_request_unittest.py	2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/pull_request_unittest.py	2021-08-27 15:16:27 UTC (rev 281695)
@@ -20,9 +20,12 @@
 # (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 os
+import sys
 import unittest
 
-from webkitscmpy import Commit, PullRequest
+from webkitcorepy import OutputCapture, testing
+from webkitscmpy import Commit, PullRequest, program, mocks
 
 
 class TestPullRequest(unittest.TestCase):
@@ -150,3 +153,169 @@
         self.assertEqual(len(commits), 1)
         self.assertEqual(commits[0].hash, '11aa76f9fc380e9fe06157154f32b304e8dc4749')
         self.assertEqual(commits[0].message, '[scoping] Bug to fix\n\nReviewed by Tim Contributor.')
+
+
+class TestDoPullRequest(testing.PathTestCase):
+    basepath = 'mock/repository'
+
+    def setUp(self):
+        super(TestDoPullRequest, self).setUp()
+        os.mkdir(os.path.join(self.path, '.git'))
+        os.mkdir(os.path.join(self.path, '.svn'))
+
+    def test_svn(self):
+        with OutputCapture() as captured, mocks.local.Git(), mocks.local.Svn(self.path):
+            self.assertEqual(1, program.main(
+                args=('pull-request',),
+                path=self.path,
+            ))
+        self.assertEqual(captured.root.log.getvalue(), '')
+        self.assertEqual(captured.stderr.getvalue(), "Can only 'pull-request' on a native Git repository\n")
+
+    def test_no_modified(self):
+        with OutputCapture() as captured, mocks.local.Git(self.path), mocks.local.Svn():
+            self.assertEqual(1, program.main(
+                args=('pull-request', '-i', 'pr-branch'),
+                path=self.path,
+            ))
+        self.assertEqual(captured.root.log.getvalue(), "Creating the local development branch 'eng/pr-branch'...\n")
+        self.assertEqual(captured.stderr.getvalue(), 'No modified files\n')
+
+    def test_staged(self):
+        with OutputCapture() as captured, mocks.local.Git(self.path) as repo, mocks.local.Svn():
+            repo.staged['added.txt'] = 'added'
+            self.assertEqual(1, program.main(
+                args=('pull-request', '-i', 'pr-branch'),
+                path=self.path,
+            ))
+            self.assertDictEqual(repo.staged, {})
+            self.assertEqual(repo.head.hash, 'e4390abc95a2026370b8c9813b7e55c61c5d6ebb')
+
+        self.assertEqual(captured.root.log.getvalue(), '''Creating the local development branch 'eng/pr-branch'...
+Creating commit...
+    Found 1 commit...
+''')
+        self.assertEqual(captured.stderr.getvalue(), "'{}' doesn't have a recognized remote\n".format(self.path))
+
+    def test_modified(self):
+        with OutputCapture() as captured, mocks.local.Git(self.path) as repo, mocks.local.Svn():
+            repo.modified['modified.txt'] = 'diff'
+            self.assertEqual(1, program.main(
+                args=('pull-request', '-i', 'pr-branch'),
+                path=self.path,
+            ))
+            self.assertDictEqual(repo.modified, dict())
+            self.assertDictEqual(repo.staged, dict())
+            self.assertEqual(repo.head.hash, 'd05082bf6707252aef3472692598a587ed3fb213')
+
+        self.assertEqual(captured.stderr.getvalue(), "'{}' doesn't have a recognized remote\n".format(self.path))
+        self.assertEqual(captured.root.log.getvalue(), '''Creating the local development branch 'eng/pr-branch'...
+    Adding modified.txt...
+Creating commit...
+    Found 1 commit...
+''')
+
+    def test_github(self):
+        with OutputCapture() as captured, mocks.remote.GitHub() as remote, \
+                mocks.local.Git(self.path, remote='https://{}'.format(remote.remote)) as repo, mocks.local.Svn():
+
+            repo.staged['added.txt'] = 'added'
+            self.assertEqual(0, program.main(
+                args=('pull-request', '-i', 'pr-branch'),
+                path=self.path,
+            ))
+
+        self.assertEqual(captured.stderr.getvalue(), '')
+        log = captured.root.log.getvalue().splitlines()
+        self.assertEqual(
+            log[:4] + log[7 if sys.version_info > (3, 0) else 5:], [
+                "Creating the local development branch 'eng/pr-branch'...",
+                'Creating commit...',
+                '    Found 1 commit...',
+                "Pushing 'eng/pr-branch' to 'fork'...",
+                "Creating pull-request for 'eng/pr-branch'...",
+                "Created 'PR 1 | Created commit'!",
+            ],
+        )
+
+    def test_github_update(self):
+        with mocks.remote.GitHub() as remote, mocks.local.Git(self.path, remote='https://{}'.format(remote.remote)) as repo, mocks.local.Svn():
+            with OutputCapture():
+                repo.staged['added.txt'] = 'added'
+                self.assertEqual(0, program.main(
+                    args=('pull-request', '-i', 'pr-branch'),
+                    path=self.path,
+                ))
+
+            with OutputCapture() as captured:
+                repo.staged['added.txt'] = 'diff'
+                self.assertEqual(0, program.main(
+                    args=('pull-request',),
+                    path=self.path,
+                ))
+
+        self.assertEqual(captured.stderr.getvalue(), '')
+        log = captured.root.log.getvalue().splitlines()
+        self.assertEqual(
+            log[:3] + log[6 if sys.version_info > (3, 0) else 4:], [
+                "Amending commit...",
+                '    Found 1 commit...',
+                "Pushing 'eng/pr-branch' to 'fork'...",
+                "Updating pull-request for 'eng/pr-branch'...",
+                "Updated 'PR 1 | Amended commit'!",
+            ],
+        )
+
+    def test_stash(self):
+        with OutputCapture() as captured, mocks.remote.BitBucket() as remote, mocks.local.Git(self.path, remote='ssh://git@{}/{}/{}.git'.format(
+            remote.hosts[0], remote.project.split('/')[1], remote.project.split('/')[3],
+        )) as repo, mocks.local.Svn():
+
+            repo.staged['added.txt'] = 'added'
+            self.assertEqual(0, program.main(
+                args=('pull-request', '-i', 'pr-branch'),
+                path=self.path,
+            ))
+
+        self.assertEqual(captured.stderr.getvalue(), '')
+        log = captured.root.log.getvalue().splitlines()
+        self.assertEqual(
+            log[:4] + log[7 if sys.version_info > (3, 0) else 5:], [
+                "Creating the local development branch 'eng/pr-branch'...",
+                'Creating commit...',
+                '    Found 1 commit...',
+                "Pushing 'eng/pr-branch' to 'origin'...",
+                "Creating pull-request for 'eng/pr-branch'...",
+                "Created 'PR 1 | Created commit'!",
+            ],
+        )
+
+    def test_stash_update(self):
+        with mocks.remote.BitBucket() as remote, mocks.local.Git(self.path, remote='ssh://git@{}/{}/{}.git'.format(
+            remote.hosts[0], remote.project.split('/')[1], remote.project.split('/')[3],
+        )) as repo, mocks.local.Svn():
+            with OutputCapture():
+                repo.staged['added.txt'] = 'added'
+                self.assertEqual(0, program.main(
+                    args=('pull-request', '-i', 'pr-branch'),
+                    path=self.path,
+                ))
+
+            with OutputCapture() as captured:
+                repo.staged['added.txt'] = 'diff'
+                self.assertEqual(0, program.main(
+                    args=('pull-request',),
+                    path=self.path,
+                ))
+
+        self.assertEqual(captured.stderr.getvalue(), '')
+        log = captured.root.log.getvalue().splitlines()
+        self.assertEqual(
+            log[:3] + log[6 if sys.version_info > (3, 0) else 4:], [
+                "Amending commit...",
+                '    Found 1 commit...',
+                "Pushing 'eng/pr-branch' to 'origin'...",
+                "Updating pull-request for 'eng/pr-branch'...",
+                "Updated 'PR 1 | Amended commit'!",
+            ],
+        )
_______________________________________________
webkit-changes mailing list
webkit-changes@lists.webkit.org
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to