← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:repository-fork-api into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:repository-fork-api into launchpad:master.

Commit message:
Allow forking Git repositories via the API

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/432118

This would be useful to the kernel team, in particular.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:repository-fork-api into launchpad:master.
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index da7dc76..ac4e0db 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -88,7 +88,6 @@ from lp.code.interfaces.gitref import IGitRefBatchNavigator
 from lp.code.interfaces.gitrepository import (
     ContributorGitIdentity,
     IGitRepository,
-    IGitRepositorySet,
 )
 from lp.code.interfaces.revisionstatus import (
     IRevisionStatusArtifactSet,
@@ -576,9 +575,7 @@ class GitRepositoryForkView(LaunchpadEditFormView):
 
     @action("Fork it", name="fork")
     def fork(self, action, data):
-        forked = getUtility(IGitRepositorySet).fork(
-            self.context, self.user, data.get("owner")
-        )
+        forked = self.context.fork(self.user, data.get("owner"))
         self.request.response.addNotification("Repository forked.")
         self.next_url = canonical_url(forked)
 
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 4b0521b..b8c75d1 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -905,6 +905,24 @@ class IGitRepositoryView(IHasRecipes):
         :param commit_sha1: The commit sha1 for the report.
         """
 
+    @call_with(requester=REQUEST_USER)
+    @operation_parameters(
+        new_owner=Reference(
+            title=_("The person who will own the forked repository."),
+            schema=IPerson,
+        )
+    )
+    # Really IGitRepository, patched in lp.code.interfaces.webservice.
+    @operation_returns_entry(Interface)
+    @export_write_operation()
+    @operation_for_version("devel")
+    def fork(requester, new_owner):
+        """Fork this repository to the given user's account.
+
+        :param requester: The IPerson performing this fork.
+        :param new_owner: The IPerson that will own the forked repository.
+        :return: The newly created GitRepository."""
+
 
 class IGitRepositoryModerateAttributes(Interface):
     """IGitRepository attributes that can be edited by more than one
@@ -1308,14 +1326,6 @@ class IGitRepositorySet(Interface):
         :param with_hosting: Create the repository on the hosting service.
         """
 
-    def fork(origin, requester, new_owner):
-        """Fork a repository to the given user's account.
-
-        :param origin: The original GitRepository.
-        :param requester: The IPerson performing this fork.
-        :param new_owner: The IPerson that will own the forked repository.
-        :return: The newly created GitRepository."""
-
     # Marker for references to Git URL layouts: ##GITNAMESPACE##
     @call_with(user=REQUEST_USER)
     @operation_parameters(
diff --git a/lib/lp/code/interfaces/webservice.py b/lib/lp/code/interfaces/webservice.py
index 203d9de..43e8436 100644
--- a/lib/lp/code/interfaces/webservice.py
+++ b/lib/lp/code/interfaces/webservice.py
@@ -211,6 +211,7 @@ patch_collection_return_type(
 patch_list_parameter_type(
     IGitRepository, "setRules", "rules", InlineObject(schema=IGitNascentRule)
 )
+patch_entry_return_type(IGitRepository, "fork", IGitRepository)
 
 # IHasBranches
 patch_collection_return_type(IHasBranches, "getBranches", IBranch)
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 64197b3..3576601 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -515,6 +515,40 @@ class GitRepository(
             self, commit_sha1
         )
 
+    def fork(self, requester, new_owner):
+        if not requester.inTeam(new_owner):
+            raise Unauthorized(
+                "The owner of the new repository must be you or a team of "
+                "which you are a member."
+            )
+        namespace = get_git_namespace(self.target, new_owner)
+        name = namespace.findUnusedName(self.name)
+        repository = getUtility(IGitRepositorySet).new(
+            repository_type=GitRepositoryType.HOSTED,
+            registrant=requester,
+            owner=new_owner,
+            target=self.target,
+            name=name,
+            information_type=self.information_type,
+            date_created=UTC_NOW,
+            description=self.description,
+            with_hosting=True,
+            async_hosting=True,
+            status=GitRepositoryStatus.CREATING,
+            clone_from_repository=self,
+        )
+        if self.target_default or self.owner_default:
+            try:
+                # If the origin is the default for its target or for its
+                # owner and target, then try to set the new repo as
+                # owner-default.
+                repository.setOwnerDefault(True)
+            except GitDefaultConflict:
+                # If there is already a owner-default for this owner/target,
+                # just move on.
+                pass
+        return repository
+
     @property
     def namespace(self):
         """See `IGitRepository`."""
@@ -2189,35 +2223,6 @@ class GitRepositorySet:
             clone_from_repository=clone_from_repository,
         )
 
-    def fork(self, origin, requester, new_owner):
-        namespace = get_git_namespace(origin.target, new_owner)
-        name = namespace.findUnusedName(origin.name)
-        repository = self.new(
-            repository_type=GitRepositoryType.HOSTED,
-            registrant=requester,
-            owner=new_owner,
-            target=origin.target,
-            name=name,
-            information_type=origin.information_type,
-            date_created=UTC_NOW,
-            description=origin.description,
-            with_hosting=True,
-            async_hosting=True,
-            status=GitRepositoryStatus.CREATING,
-            clone_from_repository=origin,
-        )
-        if origin.target_default or origin.owner_default:
-            try:
-                # If the origin is the default for its target or for its
-                # owner and target, then try to set the new repo as
-                # owner-default.
-                repository.setOwnerDefault(True)
-            except GitDefaultConflict:
-                # If there is already a owner-default for this owner/target,
-                # just move on.
-                pass
-        return repository
-
     def getByPath(self, user, path):
         """See `IGitRepositorySet`."""
         repository, extra_path = getUtility(IGitLookup).getByPath(path)
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 670b4d6..c9fa5d4 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -3730,9 +3730,7 @@ class TestGitRepositoryFork(TestCaseWithFactory):
         another_person = self.factory.makePerson()
         another_team = self.factory.makeTeam(members=[another_person])
 
-        forked_repo = getUtility(IGitRepositorySet).fork(
-            repo, another_person, another_team
-        )
+        forked_repo = repo.fork(another_person, another_team)
         self.assertThat(
             forked_repo,
             MatchesStructure(
@@ -3768,9 +3766,7 @@ class TestGitRepositoryFork(TestCaseWithFactory):
         previous_repo = self.factory.makeGitRepository(target=repo.target)
         previous_repo.setOwnerDefault(True)
 
-        forked_repo = getUtility(IGitRepositorySet).fork(
-            repo, previous_repo.owner, previous_repo.owner
-        )
+        forked_repo = repo.fork(previous_repo.owner, previous_repo.owner)
         self.assertThat(
             forked_repo,
             MatchesStructure(
@@ -3807,7 +3803,7 @@ class TestGitRepositoryFork(TestCaseWithFactory):
             owner=person, registrant=person, name=repo.name, target=repo.target
         )
 
-        forked_repo = getUtility(IGitRepositorySet).fork(repo, person, person)
+        forked_repo = repo.fork(person, person)
         self.assertThat(
             forked_repo,
             MatchesStructure(
@@ -3842,9 +3838,7 @@ class TestGitRepositoryFork(TestCaseWithFactory):
             )
         person = self.factory.makePerson()
 
-        forked_repo = getUtility(IGitRepositorySet).fork(
-            non_default_repo, person, person
-        )
+        forked_repo = non_default_repo.fork(person, person)
         self.assertThat(
             forked_repo,
             MatchesStructure(
@@ -6601,6 +6595,88 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
             response.body,
         )
 
+    def test_fork_to_self(self):
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        repository = self.factory.makeGitRepository()
+        requester = self.factory.makePerson()
+        repository_url = api_url(repository)
+        requester_url = api_url(requester)
+        webservice = webservice_for_person(
+            requester,
+            permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel",
+        )
+        response = webservice.named_post(
+            repository_url, "fork", new_owner=requester_url
+        )
+        self.assertEqual(200, response.status)
+        self.assertEndsWith(response.jsonBody()["owner_link"], requester_url)
+        self.assertEqual(1, len(hosting_fixture.create.calls))
+
+    def test_fork_to_team_as_member(self):
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        repository = self.factory.makeGitRepository()
+        requester = self.factory.makePerson()
+        team = self.factory.makeTeam(members=[requester])
+        repository_url = api_url(repository)
+        team_url = api_url(team)
+        webservice = webservice_for_person(
+            requester,
+            permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel",
+        )
+        response = webservice.named_post(
+            repository_url, "fork", new_owner=team_url
+        )
+        self.assertEqual(200, response.status)
+        self.assertEndsWith(response.jsonBody()["owner_link"], team_url)
+        self.assertEqual(1, len(hosting_fixture.create.calls))
+
+    def test_fork_to_team_as_non_member(self):
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        repository = self.factory.makeGitRepository()
+        requester = self.factory.makePerson()
+        team = self.factory.makeTeam()
+        repository_url = api_url(repository)
+        team_url = api_url(team)
+        webservice = webservice_for_person(
+            requester,
+            permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel",
+        )
+        response = webservice.named_post(
+            repository_url, "fork", new_owner=team_url
+        )
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            b"The owner of the new repository must be you or a team of which "
+            b"you are a member.",
+            response.body,
+        )
+        self.assertEqual(0, len(hosting_fixture.create.calls))
+
+    def test_fork_invisible(self):
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=owner, information_type=InformationType.USERDATA
+        )
+        requester = self.factory.makePerson()
+        with person_logged_in(owner):
+            repository_url = api_url(repository)
+            requester_url = api_url(requester)
+        webservice = webservice_for_person(
+            requester,
+            permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel",
+        )
+        response = webservice.named_post(
+            repository_url, "fork", new_owner=requester_url
+        )
+        self.assertEqual(401, response.status)
+        self.assertIn(b"launchpad.View", response.body)
+        self.assertEqual(0, len(hosting_fixture.create.calls))
+
 
 class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
     """Test GitRepository macaroon issuing and verification."""