Title: [270038] trunk/Tools
Revision
270038
Author
jbed...@apple.com
Date
2020-11-19 10:49:36 -0800 (Thu, 19 Nov 2020)

Log Message

[webkitscmpy] Support remote Subversion repository
https://bugs.webkit.org/show_bug.cgi?id=218827
<rdar://problem/71304485>

Rubber-stamped by Aakash Jain.

It is possible to interact with a Subversion repository without the svn command. This is useful
for services hosted in environments where installing binaries is burdensome, or in cases where
tools are running without a checkout.

* Scripts/libraries/webkitcorepy/webkitcorepy/__init__.py: Bump version, add dependencies.
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py:
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote: Added.
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py: Added.
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py: Added.
(Svn): Interact with a remote Subversion repository without using the svn command.
(Svn.is_webserver): Check if the provided url matches the pattern for a Subversion server.
(Svn.__init__):
(Svn.is_svn):
(Svn._latest): Return the latest revision.
(Svn.info): Return the bits of the `svn info` command that are used in code.
(Svn.default_branch):
(Svn.list):
(Svn.branches): List all branches.
(Svn.tags): List all tags.
(Svn._cache_path): Return path to json cache.
(Svn._cache_lock): Filesystem lock used to prevent contention over the json cache, this
is particularly important during testing.
(Svn._cache_revisions): Cache the identifier/revision mapping.
(Svn._branch_for): Given a commit, determine which branch that commit is on.
(Svn._commit_count): Determine the amount of commits on a branch since branching.
(Svn.commit): Return a commit object given a revision, branch, identifier or tag.
* Scripts/libraries/webkitscmpy/webkitscmpy/remote: Added.
* Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py: Added.
* Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py: Added.
(Scm):
(Scm.from_url): Given a URL, attempt to determine what kind of remote API is available.
(Scm.__init__):
* Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py: Added.
(Svn): Mock requests needed to interact with a remote Subversion repository.
(Svn.latest): Return the most recent commit on any branch.
(Svn.branches): Return branches at some revision.
(Svn.tags): Return tags at some revision.
(Svn.range): Give a start and end revision, along with a tag or branch,
return all commits in that range.
(Svn.request): Handle a small subset of requests to a Subversion server.
* Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py:
(TestLocalSvn): Moved from TestSvn.
(TestRemoteSvn): Added.
(TestSvn): Moved to TestLocalSvn.

Modified Paths

Added Paths

Diff

Modified: trunk/Tools/ChangeLog (270037 => 270038)


--- trunk/Tools/ChangeLog	2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/ChangeLog	2020-11-19 18:49:36 UTC (rev 270038)
@@ -1,3 +1,56 @@
+2020-11-19  Jonathan Bedard  <jbed...@apple.com>
+
+        [webkitscmpy] Support remote Subversion repository
+        https://bugs.webkit.org/show_bug.cgi?id=218827
+        <rdar://problem/71304485>
+
+        Rubber-stamped by Aakash Jain.
+
+        It is possible to interact with a Subversion repository without the svn command. This is useful
+        for services hosted in environments where installing binaries is burdensome, or in cases where
+        tools are running without a checkout.
+
+        * Scripts/libraries/webkitcorepy/webkitcorepy/__init__.py: Bump version, add dependencies.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py:
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote: Added.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py: Added.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py: Added.
+        (Svn): Interact with a remote Subversion repository without using the svn command.
+        (Svn.is_webserver): Check if the provided url matches the pattern for a Subversion server.
+        (Svn.__init__):
+        (Svn.is_svn):
+        (Svn._latest): Return the latest revision.
+        (Svn.info): Return the bits of the `svn info` command that are used in code.
+        (Svn.default_branch):
+        (Svn.list):
+        (Svn.branches): List all branches.
+        (Svn.tags): List all tags.
+        (Svn._cache_path): Return path to json cache.
+        (Svn._cache_lock): Filesystem lock used to prevent contention over the json cache, this
+        is particularly important during testing.
+        (Svn._cache_revisions): Cache the identifier/revision mapping.
+        (Svn._branch_for): Given a commit, determine which branch that commit is on.
+        (Svn._commit_count): Determine the amount of commits on a branch since branching.
+        (Svn.commit): Return a commit object given a revision, branch, identifier or tag.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/remote: Added.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py: Added.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py: Added.
+        (Scm):
+        (Scm.from_url): Given a URL, attempt to determine what kind of remote API is available.
+        (Scm.__init__):
+        * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py: Added.
+        (Svn): Mock requests needed to interact with a remote Subversion repository.
+        (Svn.latest): Return the most recent commit on any branch.
+        (Svn.branches): Return branches at some revision.
+        (Svn.tags): Return tags at some revision.
+        (Svn.range): Give a start and end revision, along with a tag or branch,
+        return all commits in that range.
+        (Svn.request): Handle a small subset of requests to a Subversion server.
+        * Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py:
+        (TestLocalSvn): Moved from TestSvn.
+        (TestRemoteSvn): Added.
+        (TestSvn): Moved to TestLocalSvn.
+
 2020-11-19  Per Arne Vollan  <pvol...@apple.com>
 
         [macOS] Issue sandbox extension to Web Inspector service

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py (270037 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py	2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -46,9 +46,12 @@
         "Please install webkitcorepy with `pip install webkitcorepy --extra-index-url <package index URL>`"
     )
 
-version = Version(0, 3, 2)
+version = Version(0, 4, 0)
 
 AutoInstall.register(Package('dateutil', Version(2, 8, 1), pypi_name='python-dateutil'))
+AutoInstall.register(Package('fasteners', Version(0, 15, 0)))
+AutoInstall.register(Package('monotonic', Version(1, 5)))
+AutoInstall.register(Package('xmltodict', Version(0, 12, 0)))
 
 from webkitscmpy.contributor import Contributor
 from webkitscmpy.commit import Commit

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py (270037 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py	2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -1,3 +1,4 @@
 # Copyright (C) 2020 Apple Inc. All rights reserved.
 
 from webkitscmpy.mocks import local
+from webkitscmpy.mocks import remote

Copied: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py (from rev 270037, trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py) (0 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py	                        (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,23 @@
+# Copyright (C) 2020 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.
+
+from webkitscmpy.mocks.remote.svn import Svn

Added: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py (0 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py	                        (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,381 @@
+# Copyright (C) 2020 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 os
+import re
+import xmltodict
+
+from collections import OrderedDict
+from datetime import datetime
+from webkitcorepy import mocks
+from webkitscmpy import Commit, Contributor, remote as scmremote
+
+
+class Svn(mocks.Requests):
+    top = None
+    REVISION_REQUEST_RE = re.compile(r'svn/rvr/(?P<revision>\d+)(/(?P<category>\S+))?$')
+
+    def __init__(self, remote='svn.webkit.org/repository/webkit'):
+        if not scmremote.Svn.is_webserver('https://{}'.format(remote)):
+            raise ValueError('"{}" is not a valid Svn remote'.format(remote))
+
+        super(Svn, self).__init__(remote.split('/')[0])
+        if remote[-1] != '/':
+            remote += '/'
+        self.remote = remote
+        self._cache_contents = None
+        self.patches.append(scmremote.Svn('http://{}'.format(self.remote))._cache_lock())
+
+        # Provide a reasonable set of commits to test against
+        contributor = Contributor(name='Jonathan Bedard', emails=['jbed...@apple.com'])
+        self.commits = {
+            'trunk': [
+                Commit(
+                    identifier='1@trunk',
+                    revision=1,
+                    author=contributor,
+                    timestamp=1601660100,
+                    message='1st commit\n',
+                ), Commit(
+                    identifier='2@trunk',
+                    revision=2,
+                    author=contributor,
+                    timestamp=1601661100,
+                    message='2nd commit\n',
+                ), Commit(
+                    identifier='3@trunk',
+                    revision=4,
+                    author=contributor,
+                    timestamp=1601663100,
+                    message='4th commit\n',
+                ), Commit(
+                    identifier='4@trunk',
+                    revision=6,
+                    author=contributor,
+                    timestamp=1601665100,
+                    message='6th commit\n',
+                ),
+            ], 'branch-a': [
+                Commit(
+                    identifier='2.1@branch-a',
+                    revision=3,
+                    author=contributor,
+                    timestamp=1601662100,
+                    message='3rd commit\n',
+                ), Commit(
+                    identifier='2.2@branch-a',
+                    revision=7,
+                    author=contributor,
+                    timestamp=1601666100,
+                    message='7th commit\n',
+                ),
+            ],
+        }
+        self.commits['branch-b'] = [
+            self.commits['branch-a'][0], Commit(
+                identifier='2.2@branch-b',
+                revision=5,
+                author=contributor,
+                timestamp=1601664100,
+                message='5th commit\n',
+            ), Commit(
+                identifier='2.3@branch-b',
+                revision=8,
+                author=contributor,
+                timestamp=1601667100,
+                message='8th commit\n',
+            ),
+        ]
+
+        self.commits['tags/tag-1'] = [
+            self.commits['branch-a'][0],
+            self.commits['branch-a'][1], Commit(
+                identifier='2.3@tags/tag-1',
+                revision=9,
+                author=contributor,
+                timestamp=1601668100,
+                message='9th commit\n',
+            ),
+        ]
+        self.commits['tags/tag-2'] = [
+            self.commits['branch-b'][0],
+            self.commits['branch-b'][1],
+            self.commits['branch-b'][2], Commit(
+                identifier='2.4@tags/tag-2',
+                revision=10,
+                author=contributor,
+                timestamp=1601669100,
+                message='10th commit\n',
+            ),
+        ]
+
+    def __enter__(self):
+        super(Svn, self).__enter__()
+
+        cache_path = scmremote.Svn('http://{}'.format(self.remote))._cache_path
+        if os.path.isfile(cache_path):
+            with open(cache_path, 'r') as cache:
+                self._cache_contents = cache.read()
+            os.remove(cache_path)
+
+        return self
+
+    def __exit__(self, *args, **kwargs):
+        cache_path = scmremote.Svn('http://{}'.format(self.remote))._cache_path
+        if os.path.isfile(cache_path):
+            os.remove(cache_path)
+        if self._cache_contents:
+            with open(cache_path, 'w') as cache:
+                cache.write(self._cache_contents)
+
+        super(Svn, self).__exit__(*args, **kwargs)
+
+    def latest(self):
+        latest = self.commits['trunk'][-1]
+        for branch in self.commits.values():
+            for commit in branch:
+                if commit.revision > latest.revision:
+                    latest = commit
+        return latest
+
+    def branches(self, revision=None):
+        revision = revision or self.latest()
+        branches = set()
+        for branch, commits in self.commits.items():
+            for commit in commits:
+                if commit.revision <= revision and not commit.branch.startswith('tags') and commit.branch != 'trunk':
+                    branches.add(commit.branch)
+        return sorted(branches)
+
+    def tags(self, revision=None):
+        revision = revision or self.latest()
+        tags = set()
+        for branch, commits in self.commits.items():
+            for commit in commits:
+                if commit.revision <= revision and commit.branch.startswith('tags'):
+                    tags.add(commit.branch)
+        return sorted(tags)
+
+    def range(self, category=None, start=None, end=None):
+        start = start or self.latest()
+        end = end or 1
+
+        if category and category.startswith('branches/'):
+            category = category.split('/')[-1]
+
+        if not category:
+            for commits in self.commits.values():
+                for commit in commits:
+                    if commit.revision == start:
+                        category = commit.branch
+                        break
+
+        if not category:
+            return []
+
+        result = [commit for commit in reversed(self.commits[category])]
+        if self.commits[category][0].branch_point:
+            result += [commit for commit in reversed(self.commits['trunk'][:self.commits[category][0].branch_point])]
+
+        for index in reversed(range(len(result))):
+            if result[index].revision < end:
+                result = result[:index]
+                continue
+            if result[index].revision > start:
+                result = result[index:]
+                break
+
+        return result
+
+    def request(self, method, url, data="" **kwargs):
+        if not url.startswith('http://') and not url.startswith('https://'):
+            return mocks.Response.create404(url)
+
+        data = "" if data else None
+        stripped_url = url.split('://')[-1]
+
+        # Latest revision
+        if method == 'OPTIONS' and stripped_url == self.remote and data == OrderedDict((
+            ('D:options', OrderedDict((
+                ('@xmlns:D', 'DAV:'),
+                ('D:activity-collection-set', None),
+            ))),
+        )):
+            return mocks.Response.fromText(
+                url=""
+                data=''
+                    '<D:options-response xmlns:D="DAV:">\n'
+                    '<D:activity-collection-set><D:href>/repository/webkit/!svn/act/</D:href></D:activity-collection-set></D:options-response>\n',
+                headers={
+                    'SVN-Youngest-Rev': str(self.latest().revision),
+                }
+            )
+
+        # List category
+        match = self.REVISION_REQUEST_RE.match(stripped_url.split('!')[-1])
+        if method == 'PROPFIND' and stripped_url.startswith('{}!'.format(self.remote)) and match and data == OrderedDict((
+            ('propfind', OrderedDict((
+                ('@xmlns', 'DAV:'),
+                ('prop', OrderedDict((
+                    ('resourcetype', OrderedDict((
+                        ('@xmlns', 'DAV:'),
+                    ))),
+                ))),
+            ))),
+        )):
+            links = [stripped_url[len(stripped_url.split('/')[0]):]]
+            if links[0][-1] != '/':
+                links[0] += '/'
+
+            if match.group('category') == 'branches':
+                for branch in self.branches(int(match.group('revision'))):
+                    links.append('{}{}/'.format(links[0], branch))
+
+            elif match.group('category') == 'tags':
+                for tag in self.tags(int(match.group('revision'))):
+                    links.append('{}{}/'.format(links[0][:-5], tag))
+
+            else:
+                return mocks.Response.create404(url)
+
+            return mocks.Response(
+                status_code=207,
+                url=""
+                text='<?xml version="1.0" encoding="utf-8"?>\n'
+                    '<D:multistatus xmlns:D="DAV:" xmlns:ns0="DAV:">\n'
+                    '{}</D:multistatus>\n'.format(
+                        ''.join([
+                            '<D:response xmlns:lp1="DAV:">\n'
+                            '<D:href>{}</D:href>\n'
+                            '<D:propstat>\n'
+                            '<D:prop>\n'
+                            '<lp1:resourcetype><D:collection/></lp1:resourcetype>\n'
+                            '</D:prop>\n'
+                            '<D:status>HTTP/1.1 200 OK</D:status>\n'
+                            '</D:propstat>\n'
+                            '</D:response>\n'.format(link) for link in links
+                        ])),
+            )
+
+        # Info for commit, branch or tag
+        if method == 'PROPFIND' and stripped_url.startswith('{}!'.format(self.remote)) and match and data == OrderedDict((
+            ('propfind', OrderedDict((
+                ('@xmlns', 'DAV:'),
+                ('prop', OrderedDict((
+                    ('resourcetype', OrderedDict((('@xmlns', 'DAV:'),))),
+                    ('getcontentlength', OrderedDict((('@xmlns', 'DAV:'),))),
+                    ('deadprop-count', OrderedDict((('@xmlns', 'http://subversion.tigris.org/xmlns/dav/'),))),
+                    ('version-name', OrderedDict((('@xmlns', 'DAV:'),))),
+                    ('creationdate', OrderedDict((('@xmlns', 'DAV:'),))),
+                    ('creator-displayname', OrderedDict((('@xmlns', 'DAV:'),))),
+                ))),
+            ))),
+        )):
+            branch = match.group('category')
+            if branch.startswith('branches'):
+                branch = '/'.join(branch.split('/')[1:])
+
+            if branch not in self.commits:
+                return mocks.Response.create404(url)
+            commit = self.commits[branch][0]
+            for candidate in self.commits[branch]:
+                if candidate.revision <= int(match.group('revision')):
+                    commit = candidate
+
+            stripped_url = stripped_url[len(stripped_url.split('/')[0]):]
+            if stripped_url[-1] != '/':
+                stripped_url += '/'
+
+            return mocks.Response(
+                status_code=207,
+                url=""
+                text='<?xml version="1.0" encoding="utf-8"?>\n'
+                    '<D:multistatus xmlns:D="DAV:" xmlns:ns0="DAV:">\n'
+                    '<D:response xmlns:lp1="DAV:" xmlns:lp3="http://subversion.tigris.org/xmlns/dav/" xmlns:g0="DAV:">\n'
+                    '<D:href>{}</D:href>\n'
+                    '<D:propstat>\n'
+                    '<D:prop>\n'
+                    '<lp1:resourcetype><D:collection/></lp1:resourcetype>\n'
+                    '<lp3:deadprop-count>1</lp3:deadprop-count>\n'
+                    '<lp1:version-name>{}</lp1:version-name>\n'
+                    '<lp1:creationdate>{}</lp1:creationdate>\n'
+                    '<lp1:creator-displayname>{}</lp1:creator-displayname>\n'
+                    '</D:prop>\n'
+                    '<D:status>HTTP/1.1 200 OK</D:status>\n'
+                    '</D:propstat>\n'
+                    '<D:propstat>\n'
+                    '<D:prop>\n'
+                    '<g0:getcontentlength/>\n'
+                    '</D:prop>\n'
+                    '<D:status>HTTP/1.1 404 Not Found</D:status>\n'
+                    '</D:propstat>\n'
+                    '</D:response>\n'
+                    '</D:multistatus>\n'.format(
+                        stripped_url,
+                        commit.revision,
+                        datetime.fromtimestamp(commit.timestamp).strftime('%Y-%m-%dT%H:%M:%S.103754Z'),
+                        commit.author.email,
+                ),
+            )
+
+        # Log for commit
+        if method == 'REPORT' and stripped_url.startswith('{}!'.format(self.remote)) and match and data.get('S:log-report'):
+            commits = self.range(
+                category=match.group('category'),
+                start=int(data['S:log-report']['S:start-revision']),
+                end=int(data['S:log-report']['S:end-revision']),
+            )
+
+            limit = int(data['S:log-report'].get('S:limit', 0))
+            if limit and len(commits) > limit:
+                commits = commits[:limit]
+
+            if not commits:
+                return mocks.Response.create404(url)
+
+            return mocks.Response(
+                status_code=200,
+                url=""
+                text='<?xml version="1.0" encoding="utf-8"?>\n'
+                '<S:log-report xmlns:S="svn:" xmlns:D="DAV:">\n'
+                '{}</S:log-report>\n'.format(
+                    ''.join([
+                        '<S:log-item>\n'
+                        '<D:version-name>{}</D:version-name>\n'
+                        '<S:date>{}</S:date>\n'
+                        '{}{}</S:log-item>\n'.format(
+                            commit.revision,
+                            datetime.fromtimestamp(commit.timestamp).strftime('%Y-%m-%dT%H:%M:%S.103754Z'),
+                            '' if data['S:log-report'].get('S:revpro') else '<D:comment>{}</D:comment>\n'
+                            '<D:creator-displayname>{}</D:creator-displayname>\n'.format(
+                                commit.message,
+                                commit.author.email,
+                            ),
+                            '<S:modified-path node-kind="file" text-mods="true" prop-mods="false">/{branch}/Changelog</S:modified-path>\n'
+                            '<S:modified-path node-kind="file" text-mods="true" prop-mods="false">/{branch}/file.cpp</S:modified-path>\n'.format(
+                                branch=commit.branch if commit.branch.split('/')[0] in ['trunk', 'tags'] else 'branches/{}'.format(commit.branch),
+                            ) if 'S:discover-changed-paths' in data['S:log-report'] else '',
+                        ) for commit in commits
+                    ])),
+            )
+
+        return mocks.Response.create404(url)

Copied: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py (from rev 270037, trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py) (0 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py	                        (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,24 @@
+# Copyright (C) 2020 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.
+
+from webkitscmpy.remote.scm import Scm
+from webkitscmpy.remote.svn import Svn

Copied: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py (from rev 270037, trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py) (0 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py	                        (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,42 @@
+# Copyright (C) 2020 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 six
+
+from webkitscmpy.scm_base import ScmBase
+
+
+class Scm(ScmBase):
+    @classmethod
+    def from_url(cls, url):
+        from webkitscmpy import remote
+
+        if remote.Svn.is_webserver(url):
+            return remote.Svn(url)
+        raise OSError("'{}' is not a known SCM server".format(url))
+
+    def __init__(self, url, dev_branches=None, prod_branches=None):
+        super(Scm, self).__init__(dev_branches=dev_branches, prod_branches=prod_branches)
+
+        if not isinstance(url, six.string_types):
+            raise ValueError("Expected 'url' to be a string type, not '{}'".format(type(url)))
+        self.url = ""

Added: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/svn.py (0 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/svn.py	                        (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/svn.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,446 @@
+# Copyright (C) 2020 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 bisect
+import calendar
+import fasteners
+import json
+import os
+import re
+import requests
+import tempfile
+import xmltodict
+
+from datetime import datetime
+
+from webkitcorepy import log, run, decorators
+from webkitscmpy.remote.scm import Scm
+from webkitscmpy import Commit, Contributor, Version
+
+
+class Svn(Scm):
+    URL_RE = re.compile(r'\Ahttps?://svn.(?P<host>\S+)/repository/\S+\Z')
+    HISTORY_RE = re.compile(b'<D:version-name>(?P<revision>\d+)</D:version-name>')
+    CACHE_VERSION = Version(1)
+
+    @classmethod
+    def is_webserver(cls, url):
+        return True if cls.URL_RE.match(url) else False
+
+    def __init__(self, url, dev_branches=None, prod_branches=None):
+        if url[-1] != '/':
+            url += '/'
+        if not self.is_webserver(url):
+            raise self.Exception("'{}' is not a valid SVN webserver".format(url))
+        super(Svn, self).__init__(url, dev_branches=dev_branches, prod_branches=prod_branches)
+
+        if os.path.exists(self._cache_path):
+            try:
+                with self._cache_lock(), open(self._cache_path) as file:
+                    self._metadata_cache = json.load(file)
+            except BaseException:
+                self._metadata_cache = dict(version=str(self.CACHE_VERSION))
+        else:
+            self._metadata_cache = dict(version=str(self.CACHE_VERSION))
+
+    @property
+    def is_svn(self):
+        return True
+
+    @decorators.Memoize(timeout=60)
+    def _latest(self):
+        response = requests.request(
+            method='OPTIONS',
+            url=""
+            headers={
+                'Content-Type': 'text/xml',
+                'Accept-Encoding': 'gzip',
+                'DEPTH': '1',
+            }, data=''
+            '<D:options xmlns:D="DAV:">\n'
+            '    <D:activity-collection-set></D:activity-collection-set>\n'
+            '</D:options>\n',
+        )
+        if response.status_code != 200:
+            return None
+        return int(response.headers.get('SVN-Youngest-Rev'))
+
+    @decorators.Memoize(cached=False)
+    def info(self, branch=None, revision=None, tag=None):
+        if tag and branch:
+            raise ValueError('Cannot specify both branch and tag')
+        if tag and revision:
+            raise ValueError('Cannot specify both branch and tag')
+
+        if not revision:
+            branch = branch or self.default_branch
+            revision = self._latest()
+            if not revision:
+                return None
+
+        if not revision:
+            raise ValueError('Failed to find the latest revision')
+
+        url = ''.format(self.url, revision)
+        if branch and branch != self.default_branch and '/' not in branch:
+            url = ''.format(url, branch)
+        elif tag:
+            url = ''.format(url, tag)
+        elif branch:
+            url = ''.format(url, branch or self.default_branch)
+
+        response = requests.request(
+            method='PROPFIND',
+            url=""
+            headers={
+                'Content-Type': 'text/xml',
+                'Accept-Encoding': 'gzip',
+                'DEPTH': '1',
+            }, data=''
+            '    <prop>\n'
+            '        <resourcetype xmlns="DAV:"/>\n'
+            '        <getcontentlength xmlns="DAV:"/>\n'
+            '        <deadprop-count xmlns="http://subversion.tigris.org/xmlns/dav/"/>\n'
+            '        <version-name xmlns="DAV:"/>\n'
+            '        <creationdate xmlns="DAV:"/>\n'
+            '        <creator-displayname xmlns="DAV:"/>\n'
+            '    </prop>\n'
+            '</propfind>\n',
+        )
+        if response.status_code not in [200, 207]:
+            return {}
+
+        response = xmltodict.parse(response.text)
+        response = response.get('D:multistatus', response).get('D:response', [])
+        if not response:
+            return {}
+
+        response = response[0] if isinstance(response, list) else response
+        response = response['D:propstat'][0]['D:prop']
+
+        return {
+            'Last Changed Rev': response['lp1:version-name'],
+            'Last Changed Author': response['lp1:creator-displayname'],
+            'Last Changed Date': ' '.join(response['lp1:creationdate'].split('T')).split('.')[0],
+            'Revision': revision,
+        }
+
+    @property
+    def default_branch(self):
+        return 'trunk'
+
+    def list(self, category):
+        revision = self._latest()
+        if not revision:
+            return []
+
+        response = requests.request(
+            method='PROPFIND',
+            url=''.format(self.url, revision, category),
+            headers={
+                'Content-Type': 'text/xml',
+                'Accept-Encoding': 'gzip',
+                'DEPTH': '1',
+            }, data=''
+                '<propfind xmlns="DAV:">\n'
+                '    <prop><resourcetype xmlns="DAV:"/></prop>\n'
+                '</propfind>\n',
+        )
+        if response.status_code not in [200, 207]:
+            return []
+
+        responses = xmltodict.parse(response.text)
+        responses = responses.get('D:multistatus', responses).get('D:response', [])
+
+        results = []
+        for response in responses:
+            candidate = response['D:href'].split('!svn/rvr/{}/{}/'.format(revision, category))[-1].rstrip('/')
+            if not candidate:
+                continue
+            results.append(candidate)
+
+        return results
+
+    @property
+    def branches(self):
+        return [self.default_branch] + self.list('branches')
+
+    @property
+    def tags(self):
+        return self.list('tags')
+
+    @property
+    @decorators.Memoize()
+    def _cache_path(self):
+        return os.path.join(tempfile.gettempdir(), 'svn.{}'.format(self.URL_RE.match(self.url).group('host')), 'webkitscmpy-cache.json')
+
+    def _cache_lock(self):
+        return fasteners.InterProcessLock(os.path.join(os.path.dirname(self._cache_path), 'cache.lock'))
+
+    def _cache_revisions(self, branch=None):
+        branch = branch or self.default_branch
+        is_default_branch = branch == self.default_branch
+        if branch not in self._metadata_cache:
+            self._metadata_cache[branch] = [0] if is_default_branch else []
+        pos = len(self._metadata_cache[branch])
+
+        # If we aren't on the default branch, we will need the default branch to determine when
+        # our  branch  intersects with the default branch.
+        if not is_default_branch:
+            self._cache_revisions(branch=self.default_branch)
+
+        did_warn = False
+        count = 0
+
+        latest = self._latest()
+        with requests.request(
+            method='REPORT',
+            url=''.format(
+                self.url,
+                latest,
+                branch if is_default_branch or '/' in branch else 'branches/{}'.format(branch),
+            ), stream=True,
+            headers={
+                'Content-Type': 'text/xml',
+                'Accept-Encoding': 'gzip',
+                'DEPTH': '1',
+            }, data=''
+                '<S:start-revision>{revision}</S:start-revision>\n'
+                '<S:end-revision>0</S:end-revision>\n'
+                '<S:revprop>svn:date</S:revprop>\n'
+                '</S:log-report>\n'.format(revision=latest),
+        ) as response:
+            if response.status_code != 200:
+                raise self.Exception("Failed to construct branch history for '{}'".format(branch))
+
+            for line in response.iter_lines():
+                match = self.HISTORY_RE.match(line)
+                if not match:
+                    continue
+
+                if not did_warn:
+                    count += 1
+                    if count > 1000:
+                        self.log('Caching commit data for {}, this will take a few minutes...'.format(branch))
+                        did_warn = True
+
+                revision = int(match.group('revision'))
+                if pos > 0 and self._metadata_cache[branch][pos - 1] == revision:
+                    break
+                if not is_default_branch:
+                    if revision in self._metadata_cache[self.default_branch]:
+                        self._metadata_cache[branch].insert(pos, revision)
+                        break
+                self._metadata_cache[branch].insert(pos, revision)
+
+        if self._metadata_cache[self.default_branch][0] == [0]:
+            self._metadata_cache['identifier'] = len(self._metadata_cache[branch])
+
+        try:
+            if not os.path.isdir(os.path.dirname(self._cache_path)):
+                os.makedirs(os.path.dirname(self._cache_path))
+            with self._cache_lock(), open(self._cache_path, 'w') as file:
+                json.dump(self._metadata_cache, file, indent=4)
+        except (IOError, OSError):
+            self.log("Failed to write SVN cache to '{}'".format(self._cache_path))
+
+        return self._metadata_cache[branch]
+
+    def _branch_for(self, revision):
+        response = requests.request(
+            method='REPORT',
+            url=''.format(self.url, revision),
+            headers={
+                'Content-Type': 'text/xml',
+                'Accept-Encoding': 'gzip',
+                'DEPTH': '1',
+            }, data=''
+                '<S:start-revision>{revision}</S:start-revision>\n'
+                '<S:end-revision>{revision}</S:end-revision>\n'
+                '<S:limit>1</S:limit>\n'
+                '<S:discover-changed-paths/>\n'
+                '</S:log-report>\n'.format(revision=revision),
+        )
+
+        # If we didn't get a valid answer from the remote, but we found a matching candidate, we return that.
+        # This is a bit risky because there is a chance the branch we have cached is not the canonical branch
+        # for a revision, but this is pretty unlikely because it would require the n + 1 level branch to be cached
+        # but not the n level branch.
+        if response.status_code != 200:
+            raise self.Exception("Failed to retrieve branch for '{}'".format(revision))
+
+        partial = None
+        items = xmltodict.parse(response.text)['S:log-report']['S:log-item']
+        for group in (items.get('S:modified-path', []), items.get('S:added-path', [])):
+            for item in group if isinstance(group, list) else [group]:
+                if not partial:
+                    partial = item['#text']
+                while not item['#text'].startswith(partial):
+                    partial = partial[:-1]
+
+        candidate = partial.split('/')[2 if partial.startswith('/branches') else 1]
+
+        # Tags are a unique case for SVN, because they're treated as branches in native SVN
+        if candidate == 'tags':
+            return partial[1:].rstrip('/')
+        return candidate
+
+    def _commit_count(self, revision=None, branch=None):
+        branch = branch or self.default_branch
+
+        if revision:
+            if revision not in self._metadata_cache[branch]:
+                raise self.Exception("Failed to find '{}' on '{}'".format(revision, branch))
+            return bisect.bisect_left(self._metadata_cache[branch], int(revision))
+        if branch == self.default_branch:
+            return len(self._metadata_cache[branch])
+        return self._commit_count(revision=self._metadata_cache[branch][0], branch=self.default_branch)
+
+    def commit(self, hash=None, revision=None, identifier=None, branch=None, tag=None):
+        if hash:
+            raise ValueError('SVN does not support Git hashes')
+
+        parsed_branch_point = None
+        if identifier is not None:
+            if revision:
+                raise ValueError('Cannot define both revision and identifier')
+            if tag:
+                raise ValueError('Cannot define both tag and identifier')
+
+            parsed_branch_point, identifier, parsed_branch = Commit._parse_identifier(identifier, do_assert=True)
+            if parsed_branch:
+                if branch and branch != parsed_branch:
+                    raise ValueError(
+                        "Caller passed both 'branch' and 'identifier', but specified different branches ({} and {})".format(
+                            branch, parsed_branch,
+                        ),
+                    )
+                branch = parsed_branch
+            branch = branch or self.default_branch
+
+            if branch == self.default_branch and parsed_branch_point:
+                raise self.Exception('Cannot provide a branch point for a commit on the default branch')
+
+            if not self._metadata_cache.get(branch, []) or identifier >= len(self._metadata_cache.get(branch, [])):
+                if branch != self.default_branch:
+                    self._cache_revisions(branch=self.default_branch)
+                self._cache_revisions(branch=branch)
+            if identifier > len(self._metadata_cache.get(branch, [])):
+                raise self.Exception('Identifier {} cannot be found on the specified branch in the current checkout'.format(identifier))
+
+            if identifier <= 0:
+                if branch == self.default_branch:
+                    raise self.Exception('Illegal negative identifier on the default branch')
+                identifier = self._commit_count(branch=branch) + identifier
+                if identifier < 0:
+                    raise self.Exception('Identifier does not exist on the specified branch')
+
+                branch = self.default_branch
+
+            revision = self._metadata_cache[branch][identifier]
+            info = self.info(cached=True, branch=branch, revision=revision)
+            branch = self._branch_for(revision)
+            if not self._metadata_cache.get(branch, []) or identifier >= len(self._metadata_cache.get(branch, [])):
+                self._cache_revisions(branch=branch)
+
+        elif revision:
+            if branch:
+                raise ValueError('Cannot define both branch and revision')
+            if tag:
+                raise ValueError('Cannot define both tag and revision')
+            revision = Commit._parse_revision(revision, do_assert=True)
+            branch = self._branch_for(revision)
+            info = self.info(cached=True, branch=branch, revision=revision)
+
+        else:
+            if branch and tag:
+                raise ValueError('Cannot define both branch and tag')
+
+            branch = None if tag else branch or self.default_branch
+            info = self.info(tag=tag) if tag else self.info(branch=branch)
+            if not info:
+                raise self.Exception("'{}' is not a recognized {}".format(
+                    tag or branch,
+                    'tag' if tag else 'branch',
+                ))
+            revision = int(info['Last Changed Rev'])
+            if branch != self.default_branch:
+                branch = self._branch_for(revision)
+
+        date = datetime.strptime(info['Last Changed Date'], '%Y-%m-%d %H:%M:%S')
+
+        if not identifier:
+            if branch != self.default_branch and revision > self._metadata_cache.get(self.default_branch, [0])[-1]:
+                self._cache_revisions(branch=self.default_branch)
+            if revision not in self._metadata_cache.get(branch, []):
+                self._cache_revisions(branch=branch)
+            identifier = self._commit_count(revision=revision, branch=branch)
+
+        branch_point = None if branch == self.default_branch else self._commit_count(branch=branch)
+        if branch_point and parsed_branch_point and branch_point != parsed_branch_point:
+            raise ValueError("Provided 'branch_point' does not match branch point of specified branch")
+
+        response = requests.request(
+            method='REPORT',
+            url=''.format(self.url, revision),
+            headers={
+                'Content-Type': 'text/xml',
+                'Accept-Encoding': 'gzip',
+                'DEPTH': '1',
+            }, data=''
+                    '<S:start-revision>{revision}</S:start-revision>\n'
+                    '<S:end-revision>{revision}</S:end-revision>\n'
+                    '<S:limit>1</S:limit>\n'
+                    '</S:log-report>\n'.format(revision=revision),
+        )
+
+        if response.status_code == 200:
+            response = xmltodict.parse(response.text)
+            response = response.get('S:log-report', {}).get('S:log-item')
+
+            name = response.get('D:creator-displayname')
+            message = response.get('D:comment', None)
+            if not name:
+                raise self.Exception('Failed to find creator name')
+
+        else:
+            self.log('Failed to connect to remote, cannot compute commit message')
+            message = None
+            name = info['Last Changed Author']
+
+        author = Contributor.by_email.get(
+            name,
+            Contributor.by_name.get(
+                name,
+                Contributor(name=name, emails=[name] if '@' in name else []),
+            ),
+        )
+
+        return Commit(
+            revision=int(revision),
+            branch=branch,
+            identifier=identifier,
+            branch_point=branch_point,
+            timestamp=int(calendar.timegm(date.timetuple())),
+            author=author,
+            message=message,
+        )

Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py (270037 => 270038)


--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py	2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py	2020-11-19 18:49:36 UTC (rev 270038)
@@ -27,10 +27,10 @@
 
 from datetime import datetime
 from webkitcorepy import OutputCapture
-from webkitscmpy import local, mocks
+from webkitscmpy import local, mocks, remote
 
 
-class TestSvn(unittest.TestCase):
+class TestLocalSvn(unittest.TestCase):
     path = '/mock/repository'
 
     def test_detection(self):
@@ -217,3 +217,87 @@
 
             self.assertEqual(9, repository.checkout('tag-1').revision)
             self.assertEqual(9, repository.commit().revision)
+
+
+class TestRemoteSvn(unittest.TestCase):
+    remote = 'https://svn.webkit.org/repository/webkit'
+
+    def test_detection(self):
+        self.assertEqual(remote.Svn.is_webserver('https://svn.webkit.org/repository/webkit'), True)
+        self.assertEqual(remote.Svn.is_webserver('http://svn.webkit.org/repository/webkit'), True)
+        self.assertEqual(remote.Svn.is_webserver('https://github.com/WebKit/webkit'), False)
+
+    def test_branches(self):
+        with mocks.remote.Svn():
+            self.assertEqual(
+                remote.Svn(self.remote).branches,
+                ['trunk', 'branch-a', 'branch-b'],
+            )
+
+    def test_tags(self):
+        with mocks.remote.Svn():
+            self.assertEqual(
+                remote.Svn(self.remote).tags,
+                ['tag-1', 'tag-2'],
+            )
+
+    def test_scm_type(self):
+        self.assertTrue(remote.Svn(self.remote).is_svn)
+        self.assertFalse(remote.Svn(self.remote).is_git)
+
+    def test_info(self):
+        with mocks.remote.Svn():
+            self.assertDictEqual({
+                'Last Changed Author': 'jbed...@apple.com',
+                'Last Changed Date': '2020-10-02 11:58:20',
+                'Last Changed Rev': '6',
+                'Revision': 10,
+            }, remote.Svn(self.remote).info())
+
+    def test_commit_revision(self):
+        with mocks.remote.Svn():
+            self.assertEqual('1@trunk', str(remote.Svn(self.remote).commit(revision=1)))
+            self.assertEqual('2@trunk', str(remote.Svn(self.remote).commit(revision=2)))
+            self.assertEqual('2.1@branch-a', str(remote.Svn(self.remote).commit(revision=3)))
+            self.assertEqual('3@trunk', str(remote.Svn(self.remote).commit(revision=4)))
+            self.assertEqual('2.2@branch-b', str(remote.Svn(self.remote).commit(revision=5)))
+            self.assertEqual('4@trunk', str(remote.Svn(self.remote).commit(revision=6)))
+            self.assertEqual('2.2@branch-a', str(remote.Svn(self.remote).commit(revision=7)))
+            self.assertEqual('2.3@branch-b', str(remote.Svn(self.remote).commit(revision=8)))
+
+            # Out-of-bounds commit
+            with self.assertRaises(remote.Svn.Exception):
+                self.assertEqual(None, remote.Svn(self.remote).commit(revision=11))
+
+    def test_commit_from_branch(self):
+        with mocks.remote.Svn():
+            self.assertEqual('4@trunk', str(remote.Svn(self.remote).commit(branch='trunk')))
+            self.assertEqual('2.2@branch-a', str(remote.Svn(self.remote).commit(branch='branch-a')))
+            self.assertEqual('2.3@branch-b', str(remote.Svn(self.remote).commit(branch='branch-b')))
+
+    def test_identifier(self):
+        with mocks.remote.Svn():
+            self.assertEqual(1, remote.Svn(self.remote).commit(identifier='1@trunk').revision)
+            self.assertEqual(2, remote.Svn(self.remote).commit(identifier='2@trunk').revision)
+            self.assertEqual(3, remote.Svn(self.remote).commit(identifier='2.1@branch-a').revision)
+            self.assertEqual(4, remote.Svn(self.remote).commit(identifier='3@trunk').revision)
+            self.assertEqual(5, remote.Svn(self.remote).commit(identifier='2.2@branch-b').revision)
+            self.assertEqual(6, remote.Svn(self.remote).commit(identifier='4@trunk').revision)
+            self.assertEqual(7, remote.Svn(self.remote).commit(identifier='2.2@branch-a').revision)
+            self.assertEqual(8, remote.Svn(self.remote).commit(identifier='2.3@branch-b').revision)
+
+    def test_non_cannonical_identifiers(self):
+        with mocks.remote.Svn():
+            self.assertEqual('2@trunk', str(remote.Svn(self.remote).commit(identifier='0@branch-a')))
+            self.assertEqual('1@trunk', str(remote.Svn(self.remote).commit(identifier='-1@branch-a')))
+
+            self.assertEqual('2@trunk', str(remote.Svn(self.remote).commit(identifier='0@branch-b')))
+            self.assertEqual('1@trunk', str(remote.Svn(self.remote).commit(identifier='-1@branch-b')))
+
+    def test_tag(self):
+        with mocks.remote.Svn():
+            self.assertEqual(9, remote.Svn(self.remote).commit(tag='tag-1').revision)
+
+    def test_tag_previous(self):
+        with mocks.remote.Svn():
+            self.assertEqual(7, remote.Svn(self.remote).commit(identifier='2.2@tags/tag-1').revision)
_______________________________________________
webkit-changes mailing list
webkit-changes@lists.webkit.org
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to