<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[281695] trunk/Tools</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.webkit.org/projects/webkit/changeset/281695">281695</a></dd>
<dt>Author</dt> <dd>jbedard@apple.com</dd>
<dt>Date</dt> <dd>2021-08-27 08:16:27 -0700 (Fri, 27 Aug 2021)</dd>
</dl>

<h3>Log Message</h3>
<pre>[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):</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunkToolsChangeLog">trunk/Tools/ChangeLog</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpysetuppy">trunk/Tools/Scripts/libraries/webkitscmpy/setup.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpy__init__py">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpymockslocalgitpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/local/git.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpymocksremotebitbucketpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/bitbucket.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpymocksremotegit_hubpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/git_hub.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpyprogram__init__py">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/__init__.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpyremotebitbucketpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/bitbucket.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpyremotegit_hubpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/git_hub.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpyremotescmpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py</a></li>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpytestpull_request_unittestpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/pull_request_unittest.py</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#trunkToolsScriptslibrarieswebkitscmpywebkitscmpyprogrampull_requestpy">trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/pull_request.py</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunkToolsChangeLog"></a>
<div class="modfile"><h4>Modified: trunk/Tools/ChangeLog (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/ChangeLog    2021-08-27 14:51:58 UTC (rev 281694)
+++ trunk/Tools/ChangeLog       2021-08-27 15:16:27 UTC (rev 281695)
</span><span class="lines">@@ -1,5 +1,60 @@
</span><span class="cx"> 2021-08-27  Jonathan Bedard  <jbedard@apple.com>
</span><span class="cx"> 
</span><ins>+        [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  <jbedard@apple.com>
+
</ins><span class="cx">         [run-webkit-tests] Use Python 3 (Part 3)
</span><span class="cx">         https://bugs.webkit.org/show_bug.cgi?id=226658
</span><span class="cx">         <rdar://problem/78882016>
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpysetuppy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/setup.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -29,7 +29,7 @@
</span><span class="cx"> 
</span><span class="cx"> setup(
</span><span class="cx">     name='webkitscmpy',
</span><del>-    version='1.1.9',
</del><ins>+    version='2.0.0',
</ins><span class="cx">     description='Library designed to interact with git and svn repositories.',
</span><span class="cx">     long_description=readme(),
</span><span class="cx">     classifiers=[
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpy__init__py"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -46,7 +46,7 @@
</span><span class="cx">         "Please install webkitcorepy with `pip install webkitcorepy --extra-index-url <package index URL>`"
</span><span class="cx">     )
</span><span class="cx"> 
</span><del>-version = Version(1, 1, 9)
</del><ins>+version = Version(2, 0, 0)
</ins><span class="cx"> 
</span><span class="cx"> AutoInstall.register(Package('fasteners', Version(0, 15, 0)))
</span><span class="cx"> AutoInstall.register(Package('monotonic', Version(1, 5)))
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpymockslocalgitpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/local/git.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -20,6 +20,7 @@
</span><span class="cx"> # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
</span><span class="cx"> # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</span><span class="cx"> 
</span><ins>+import hashlib
</ins><span class="cx"> import json
</span><span class="cx"> import os
</span><span class="cx"> import re
</span><span class="lines">@@ -28,7 +29,7 @@
</span><span class="cx"> from datetime import datetime
</span><span class="cx"> from mock import patch
</span><span class="cx"> 
</span><del>-from webkitcorepy import decorators, mocks, OutputCapture, StringIO
</del><ins>+from webkitcorepy import decorators, mocks, string_utils, OutputCapture, StringIO
</ins><span class="cx"> from webkitscmpy import local, Commit, Contributor
</span><span class="cx"> from webkitscmpy.program.canonicalize.committer import main as committer_main
</span><span class="cx"> from webkitscmpy.program.canonicalize.message import main as message_main
</span><span class="lines">@@ -412,6 +413,22 @@
</span><span class="cx">                 generator=lambda *args, **kwargs:
</span><span class="cx">                     mocks.ProcessCompletion(returncode=0) if re.match(r'^[A-Za-z0-9-]+/[A-Za-z0-9/-]+$', args[2]) else mocks.ProcessCompletion(),
</span><span class="cx">             ), mocks.Subprocess.Route(
</span><ins>+                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(
</ins><span class="cx">                 self.executable,
</span><span class="cx">                 cwd=self.path,
</span><span class="cx">                 completion=mocks.ProcessCompletion(
</span><span class="lines">@@ -519,6 +536,9 @@
</span><span class="cx">             self.commits[something][-1] = Commit.from_json(Commit.Encoder().default(self.head))
</span><span class="cx">             self.head = self.commits[something][-1]
</span><span class="cx">             self.head.branch = something
</span><ins>+            if not self.head.branch_point:
+                self.head.branch_point = self.head.identifier
+                self.head.identifier = 0
</ins><span class="cx">             return True
</span><span class="cx"> 
</span><span class="cx">         if commit:
</span><span class="lines">@@ -709,3 +729,35 @@
</span><span class="cx">                 configfile.write('\t{}={}\n'.format(key_b, value))
</span><span class="cx"> 
</span><span class="cx">         return mocks.ProcessCompletion(returncode=0)
</span><ins>+
+    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)
</ins></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpymocksremotebitbucketpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/bitbucket.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -53,6 +53,7 @@
</span><span class="cx"> 
</span><span class="cx">         self.head = self.commits[self.default_branch][-1]
</span><span class="cx">         self.tags = {}
</span><ins>+        self.pull_requests = []
</ins><span class="cx"> 
</span><span class="cx">     def commit(self, ref):
</span><span class="cx">         if ref in self.commits:
</span><span class="lines">@@ -157,7 +158,7 @@
</span><span class="cx">             ],
</span><span class="cx">         ), url=url)
</span><span class="cx"> 
</span><del>-    def request(self, method, url, data=None, params=None, **kwargs):
</del><ins>+    def request(self, method, url, data=None, params=None, json=None, **kwargs):
</ins><span class="cx">         if not url.startswith('http://') and not url.startswith('https://'):
</span><span class="cx">             return mocks.Response.create404(url)
</span><span class="cx"> 
</span><span class="lines">@@ -194,4 +195,47 @@
</span><span class="cx">         if stripped_url.startswith('{}/rest/branch-utils/latest/{}/branches/info/'.format(self.hosts[0], self.project)):
</span><span class="cx">             return self._branches_for(stripped_url.split('/')[-1], url, params or {})
</span><span class="cx"> 
</span><ins>+        # 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='committer@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])
+
</ins><span class="cx">         return mocks.Response.create404(url)
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpymocksremotegit_hubpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/git_hub.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -58,6 +58,7 @@
</span><span class="cx"> 
</span><span class="cx">         self.head = self.commits[self.default_branch][-1]
</span><span class="cx">         self.tags = {}
</span><ins>+        self.pull_requests = []
</ins><span class="cx">         self._environment = None
</span><span class="cx"> 
</span><span class="cx">     def __enter__(self):
</span><span class="lines">@@ -280,7 +281,7 @@
</span><span class="cx">             ), url=url
</span><span class="cx">         )
</span><span class="cx"> 
</span><del>-    def request(self, method, url, data=None, params=None, **kwargs):
</del><ins>+    def request(self, method, url, data=None, params=None, auth=None, json=None, **kwargs):
</ins><span class="cx">         if not url.startswith('http://') and not url.startswith('https://'):
</span><span class="cx">             return mocks.Response.create404(url)
</span><span class="cx"> 
</span><span class="lines">@@ -331,9 +332,65 @@
</span><span class="cx"> 
</span><span class="cx">         # Add fork
</span><span class="cx">         if stripped_url.startswith('{}/forks'.format(self.api_remote)) and method == 'POST':
</span><del>-            username = kwargs.get('json', {}).get('owner', None)
</del><ins>+            username = (json or {}).get('owner', None)
</ins><span class="cx">             if username:
</span><span class="cx">                 self.forks.append(username)
</span><span class="cx">             return mocks.Response.fromJson({}) if username else mocks.Response.create404(url)
</span><span class="cx"> 
</span><ins>+        # 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])
+
</ins><span class="cx">         return mocks.Response.create404(url)
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpyprogram__init__py"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/__init__.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -34,6 +34,7 @@
</span><span class="cx"> from .find import Find, Info
</span><span class="cx"> from .log import Log
</span><span class="cx"> from .pull import Pull
</span><ins>+from .pull_request import PullRequest
</ins><span class="cx"> from .setup_git_svn import SetupGitSvn
</span><span class="cx"> from .setup import Setup
</span><span class="cx"> 
</span><span class="lines">@@ -65,8 +66,7 @@
</span><span class="cx">     )
</span><span class="cx"> 
</span><span class="cx">     subparsers = parser.add_subparsers(help='sub-command help')
</span><del>-
-    programs = [Branch, Blame, Canonicalize, Checkout, Clean, Find, Info, Log, Pull, Setup]
</del><ins>+    programs = [Blame, Branch, Canonicalize, Checkout, Clean, Find, Info, Log, Pull, PullRequest, Setup]
</ins><span class="cx">     if subversion:
</span><span class="cx">         programs.append(SetupGitSvn)
</span><span class="cx"> 
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpyprogrampull_requestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/program/pull_request.py (0 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,163 @@
</span><ins>+# 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=arguments.NoAction,
+        )
+
+    @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
</ins></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpyremotebitbucketpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/bitbucket.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -25,8 +25,10 @@
</span><span class="cx"> import six
</span><span class="cx"> import sys
</span><span class="cx"> 
</span><ins>+import json
+
</ins><span class="cx"> from webkitcorepy import decorators
</span><del>-from webkitscmpy import Commit
</del><ins>+from webkitscmpy import Commit, PullRequest
</ins><span class="cx"> from webkitscmpy.remote.scm import Scm
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="lines">@@ -33,6 +35,150 @@
</span><span class="cx"> class BitBucket(Scm):
</span><span class="cx">     URL_RE = re.compile(r'\Ahttps?://(?P<domain>\S+)/projects/(?P<project>\S+)/repos/(?P<repository>\S+)\Z')
</span><span class="cx"> 
</span><ins>+    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 = self.repository.request('pull-requests', 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 = response.json()
+            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 = response.json()
+            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 = response.json()
+
+            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
+
+
</ins><span class="cx">     @classmethod
</span><span class="cx">     def is_webserver(cls, url):
</span><span class="cx">         return True if cls.URL_RE.match(url) else False
</span><span class="lines">@@ -52,6 +198,8 @@
</span><span class="cx">             id=id or self.name.lower(),
</span><span class="cx">         )
</span><span class="cx"> 
</span><ins>+        self.pull_requests = self.PRGenerator(self)
+
</ins><span class="cx">     @property
</span><span class="cx">     def is_git(self):
</span><span class="cx">         return True
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpyremotegit_hubpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/git_hub.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -29,7 +29,7 @@
</span><span class="cx"> from datetime import datetime
</span><span class="cx"> from requests.auth import HTTPBasicAuth
</span><span class="cx"> from webkitcorepy import credentials, decorators
</span><del>-from webkitscmpy import Commit
</del><ins>+from webkitscmpy import Commit, PullRequest
</ins><span class="cx"> from webkitscmpy.remote.scm import Scm
</span><span class="cx"> from xml.dom import minidom
</span><span class="cx"> 
</span><span class="lines">@@ -38,6 +38,101 @@
</span><span class="cx">     URL_RE = re.compile(r'\Ahttps?://github.(?P<domain>\S+)/(?P<owner>\S+)/(?P<repository>\S+)\Z')
</span><span class="cx">     EMAIL_RE = re.compile(r'(?P<email>[^@]+@[^@]+)(@.*)?')
</span><span class="cx"> 
</span><ins>+    class PRGenerator(Scm.PRGenerator):
+        def find(self, state=None, head=None, base=None):
+            if not state:
+                state = 'all'
+            user, _ = self.repository.credentials()
+            data = self.repository.request('pulls', 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 = response.json()
+            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 = response.json()
+
+            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
+
+
</ins><span class="cx">     @classmethod
</span><span class="cx">     def is_webserver(cls, url):
</span><span class="cx">         return True if cls.URL_RE.match(url) else False
</span><span class="lines">@@ -62,6 +157,8 @@
</span><span class="cx">             id=id or self.name.lower(),
</span><span class="cx">         )
</span><span class="cx"> 
</span><ins>+        self.pull_requests = self.PRGenerator(self)
+
</ins><span class="cx">     def credentials(self, required=True):
</span><span class="cx">         username, token = credentials(
</span><span class="cx">             url=self.api_url,
</span></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpyremotescmpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -26,6 +26,20 @@
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx"> class Scm(ScmBase):
</span><ins>+    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()
+
+
</ins><span class="cx">     @classmethod
</span><span class="cx">     def from_url(cls, url, contributors=None):
</span><span class="cx">         from webkitscmpy import remote
</span><span class="lines">@@ -42,3 +56,4 @@
</span><span class="cx">         if not isinstance(url, six.string_types):
</span><span class="cx">             raise ValueError("Expected 'url' to be a string type, not '{}'".format(type(url)))
</span><span class="cx">         self.url = url
</span><ins>+        self.pull_requests = None
</ins></span></pre></div>
<a id="trunkToolsScriptslibrarieswebkitscmpywebkitscmpytestpull_request_unittestpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/pull_request_unittest.py (281694 => 281695)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -20,9 +20,12 @@
</span><span class="cx"> # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
</span><span class="cx"> # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</span><span class="cx"> 
</span><ins>+import os
+import sys
</ins><span class="cx"> import unittest
</span><span class="cx"> 
</span><del>-from webkitscmpy import Commit, PullRequest
</del><ins>+from webkitcorepy import OutputCapture, testing
+from webkitscmpy import Commit, PullRequest, program, mocks
</ins><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx"> class TestPullRequest(unittest.TestCase):
</span><span class="lines">@@ -150,3 +153,169 @@
</span><span class="cx">         self.assertEqual(len(commits), 1)
</span><span class="cx">         self.assertEqual(commits[0].hash, '11aa76f9fc380e9fe06157154f32b304e8dc4749')
</span><span class="cx">         self.assertEqual(commits[0].message, '[scoping] Bug to fix\n\nReviewed by Tim Contributor.')
</span><ins>+
+
+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'!",
+            ],
+        )
</ins></span></pre>
</div>
</div>

</body>
</html>