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."""
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp