← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:githosting-copy-and-delete-refs into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:githosting-copy-and-delete-refs into launchpad:master.

Commit message:
Adding to GitHosting the API calls to copy and delete refs

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/390339
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:githosting-copy-and-delete-refs into launchpad:master.
diff --git a/lib/lp/code/errors.py b/lib/lp/code/errors.py
index 85de140..bed7db5 100644
--- a/lib/lp/code/errors.py
+++ b/lib/lp/code/errors.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Errors used in the lp/code modules."""
@@ -34,6 +34,7 @@ __all__ = [
     'ClaimReviewFailed',
     'DiffNotFound',
     'GitDefaultConflict',
+    'GitReferenceDeletionFault',
     'GitRepositoryBlobNotFound',
     'GitRepositoryBlobUnsupportedRemote',
     'GitRepositoryCreationException',
@@ -493,6 +494,10 @@ class GitRepositoryDeletionFault(Exception):
     """Raised when there is a fault deleting a repository."""
 
 
+class GitReferenceDeletionFault(Exception):
+    """Raised when there is a fault deleting a repository's ref."""
+
+
 class GitTargetError(Exception):
     """Raised when there is an error determining a Git repository target."""
 
diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py
index 378930a..f6433ed 100644
--- a/lib/lp/code/interfaces/githosting.py
+++ b/lib/lp/code/interfaces/githosting.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interface for communication with the Git hosting service."""
@@ -129,3 +129,21 @@ class IGitHostingClient(Interface):
         :param logger: An optional logger.
         :return: A binary string with the blob content.
         """
+
+    def copyRefs(self, path, operations, logger=None):
+        """Executes the copy of refs or commits between different
+        repositories.
+
+        :param path: Physical path of the repository on the hosting service.
+        :param operations: A list of RefCopyOperation objects describing
+                           source and target of the copy.
+        :param logger: An optional logger.
+        """
+
+    def deleteRef(self, path, ref, logger=None):
+        """Deletes a reference on the given git repository.
+
+        :param path: Physical path of the repository on the hosting service.
+        :param ref: The reference to be delete.
+        :param logger: An optional logger.
+        """
diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py
index 94d6538..b957574 100644
--- a/lib/lp/code/model/githosting.py
+++ b/lib/lp/code/model/githosting.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Communication with the Git hosting service."""
@@ -6,6 +6,7 @@
 __metaclass__ = type
 __all__ = [
     'GitHostingClient',
+    'RefCopyOperation',
     ]
 
 import base64
@@ -14,7 +15,10 @@ import sys
 
 from lazr.restful.utils import get_current_browser_request
 import requests
-from six import reraise
+from six import (
+    ensure_text,
+    reraise,
+    )
 from six.moves.urllib.parse import (
     quote,
     urljoin,
@@ -22,10 +26,13 @@ from six.moves.urllib.parse import (
 from zope.interface import implementer
 
 from lp.code.errors import (
+    GitReferenceDeletionFault,
     GitRepositoryBlobNotFound,
     GitRepositoryCreationFault,
     GitRepositoryDeletionFault,
     GitRepositoryScanFault,
+    GitTargetError,
+    NoSuchGitReference,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.services.config import config
@@ -41,6 +48,18 @@ class RequestExceptionWrapper(requests.RequestException):
     """A non-requests exception that occurred during a request."""
 
 
+class RefCopyOperation:
+    """A description of a ref (or commit) copy between repositories.
+
+    This class is just a helper to define copy operations parameters on
+    IGitHostingClient.copyRefs method.
+    """
+    def __init__(self, source_ref, target_repo, target_ref):
+        self.source_ref = source_ref
+        self.target_repo = target_repo
+        self.target_ref = target_ref
+
+
 @implementer(IGitHostingClient)
 class GitHostingClient:
     """A client for the internal API provided by the Git hosting system."""
@@ -237,3 +256,39 @@ class GitHostingClient:
         except Exception as e:
             raise GitRepositoryScanFault(
                 "Failed to get file from Git repository: %s" % unicode(e))
+
+    def copyRefs(self, path, operations, logger=None):
+        """See `IGitHostingClient`."""
+        json_data = {
+            "operations": [{
+                "from": i.source_ref,
+                "to": {"repo": i.target_repo, "ref": i.target_ref}
+            } for i in operations]
+        }
+        try:
+            if logger is not None:
+                logger.info(
+                    "Copying refs from %s to %s targets" %
+                    (path, len(operations)))
+            url = "/repo/%s/refs-copy" % path
+            self._post(url, json=json_data)
+        except requests.RequestException as e:
+            if (e.response is not None and
+                    e.response.status_code == requests.codes.NOT_FOUND):
+                raise GitTargetError(
+                    "Could not find repository %s or one of its refs" %
+                    ensure_text(path))
+            else:
+                raise GitRepositoryScanFault(
+                    "Could not copy refs: HTTP %s" % e.response.status_code)
+
+    def deleteRef(self, path, ref, logger=None):
+        try:
+            if logger is not None:
+                logger.info("Delete from repo %s the ref %s" % (path, ref))
+            url = "/repo/%s/%s" % (path, ref)
+            self._delete(url)
+        except requests.RequestException as e:
+            raise GitReferenceDeletionFault(
+                "Error deleting %s from repo %s: HTTP %s" %
+                (ref, path, e.response.status_code))
diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
index d966dbe..04f7a8e 100644
--- a/lib/lp/code/model/tests/test_githosting.py
+++ b/lib/lp/code/model/tests/test_githosting.py
@@ -2,7 +2,7 @@
 # NOTE: The first line above must stay first; do not move the copyright
 # notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
 #
-# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Unit tests for `GitHostingClient`.
@@ -37,12 +37,16 @@ from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
 
 from lp.code.errors import (
+    GitReferenceDeletionFault,
     GitRepositoryBlobNotFound,
     GitRepositoryCreationFault,
     GitRepositoryDeletionFault,
     GitRepositoryScanFault,
+    GitTargetError,
+    NoSuchGitReference,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
+from lp.code.model.githosting import RefCopyOperation
 from lp.services.job.interfaces.job import (
     IRunnableJob,
     JobStatus,
@@ -400,6 +404,46 @@ class TestGitHostingClient(TestCase):
                 " (256 vs 0)",
                 self.client.getBlob, "123", "dir/path/file/name")
 
+    def getCopyRefOperations(self):
+        return [
+            RefCopyOperation("1a2b3c4", "999", "refs/merge/123"),
+            RefCopyOperation("9a8b7c6", "666", "refs/merge/989"),
+        ]
+
+    def test_copyRefs(self):
+        with self.mockRequests("POST", status=202):
+            self.client.copyRefs("123", self.getCopyRefOperations())
+        self.assertRequest("repo/123/refs-copy", {
+            "operations": [
+                {
+                    "from": "1a2b3c4",
+                    "to": {"repo": "999", "ref": "refs/merge/123"}
+                }, {
+                    "from": "9a8b7c6",
+                    "to": {"repo": "666", "ref": "refs/merge/989"}
+                }
+            ]
+        }, "POST")
+
+    def test_copyRefs_refs_not_found(self):
+        with self.mockRequests("POST", status=404):
+            self.assertRaisesWithContent(
+                GitTargetError,
+                "Could not find repository 123 or one of its refs",
+                self.client.copyRefs, "123", self.getCopyRefOperations())
+
+    def test_deleteRef(self):
+        with self.mockRequests("DELETE", status=202):
+            self.client.deleteRef("123", "refs/merge/123")
+        self.assertRequest("repo/123/refs/merge/123", method="DELETE")
+
+    def test_deleteRef_refs_request_error(self):
+        with self.mockRequests("DELETE", status=500):
+            self.assertRaisesWithContent(
+                GitReferenceDeletionFault,
+                "Error deleting refs/merge/123 from repo 123: HTTP 500",
+                self.client.deleteRef, "123", "refs/merge/123")
+
     def test_works_in_job(self):
         # `GitHostingClient` is usable from a running job.
         @implementer(IRunnableJob)