Merge ~pappacena/launchpad:git-fork-backend into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 9867039b20fc26412e770d5474d53ad45edefac6
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:git-fork-backend
Merge into: launchpad:master
Diff against target: 412 lines (+138/-24)
13 files modified
lib/lp/code/interfaces/githosting.py (+4/-2)
lib/lp/code/interfaces/gitnamespace.py (+10/-2)
lib/lp/code/interfaces/gitrepository.py (+8/-0)
lib/lp/code/model/githosting.py (+9/-2)
lib/lp/code/model/gitjob.py (+2/-1)
lib/lp/code/model/gitnamespace.py (+14/-2)
lib/lp/code/model/gitrepository.py (+20/-4)
lib/lp/code/model/tests/test_githosting.py (+10/-2)
lib/lp/code/model/tests/test_gitjob.py (+1/-1)
lib/lp/code/model/tests/test_gitrepository.py (+34/-1)
lib/lp/code/tests/helpers.py (+1/-1)
lib/lp/code/xmlrpc/git.py (+9/-3)
lib/lp/code/xmlrpc/tests/test_git.py (+16/-3)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+387146@code.launchpad.net

Commit message

Fork method on GitRepository to allow users to asynchronously create a copy of a repository

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

This MP depends on https://code.launchpad.net/~pappacena/turnip/+git/turnip/+merge/386461 being approved and landed in production.

Revision history for this message
Colin Watson (cjwatson) wrote :

I haven't completely gone through this yet, but here are a few high-level/structural comments.

review: Needs Information
4774c21... by Thiago F. Pappacena

Merge branch 'master' into git-fork-backend

249af52... by Thiago F. Pappacena

Running confirmation job with existing db user

c313a77... by Thiago F. Pappacena

separating user that requested a fork and the new repository owner

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I'm pushing some of the requested changes, and I could use your opinion on one of the comments, cjwatson.

Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Thiago F. Pappacena (pappacena) :
f56198c... by Thiago F. Pappacena

Remove polling job to confirm repo creation

2eff6c9... by Thiago F. Pappacena

Loose permission check for confirm/abort repo creation

730a9dc... by Thiago F. Pappacena

Avoiding race condition on create/confirm API calls

6fdbd8c... by Thiago F. Pappacena

Fixing test

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
ded71d0... by Thiago F. Pappacena

Merge branch 'master' into git-fork-backend

9867039... by Thiago F. Pappacena

Fixing repository name clash when forking

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested changes. This should be good to go now.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py
2index 378930a..d854174 100644
3--- a/lib/lp/code/interfaces/githosting.py
4+++ b/lib/lp/code/interfaces/githosting.py
5@@ -1,4 +1,4 @@
6-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
7+# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
8 # GNU Affero General Public License version 3 (see the file LICENSE).
9
10 """Interface for communication with the Git hosting service."""
11@@ -14,13 +14,15 @@ from zope.interface import Interface
12 class IGitHostingClient(Interface):
13 """Interface for the internal API provided by the Git hosting service."""
14
15- def create(path, clone_from=None):
16+ def create(path, clone_from=None, async_create=False):
17 """Create a Git repository.
18
19 :param path: Physical path of the new repository on the hosting
20 service.
21 :param clone_from: If not None, clone the new repository from this
22 other physical path.
23+ :param async_create: Do not block the call until the repository is
24+ actually created.
25 """
26
27 def getProperties(path):
28diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py
29index 9c6886b..3a6e600 100644
30--- a/lib/lp/code/interfaces/gitnamespace.py
31+++ b/lib/lp/code/interfaces/gitnamespace.py
32@@ -40,8 +40,16 @@ class IGitNamespace(Interface):
33 def createRepository(repository_type, registrant, name,
34 information_type=None, date_created=None,
35 target_default=False, owner_default=False,
36- with_hosting=False, status=None):
37- """Create and return an `IGitRepository` in this namespace."""
38+ with_hosting=False, async_hosting=False, status=None):
39+ """Create and return an `IGitRepository` in this namespace.
40+
41+ :param with_hosting: If True, also creates the repository on git
42+ hosting service.
43+ :param async_hosting: If with_hosting is True, this controls if the
44+ call to create repository on hosting service will be done
45+ asynchronously, or it will block until the service creates the
46+ repository.
47+ """
48
49 def isNameUsed(name):
50 """Is 'name' already used in this namespace?"""
51diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
52index 344bc43..201c9a4 100644
53--- a/lib/lp/code/interfaces/gitrepository.py
54+++ b/lib/lp/code/interfaces/gitrepository.py
55@@ -968,6 +968,14 @@ class IGitRepositorySet(Interface):
56 :param with_hosting: Create the repository on the hosting service.
57 """
58
59+ def fork(origin, requester, new_owner):
60+ """Fork a repository to the given user's account.
61+
62+ :param origin: The original GitRepository.
63+ :param requester: The IPerson performing this fork.
64+ :param new_owner: The IPerson that will own the forked repository.
65+ :return: The newly created GitRepository."""
66+
67 # Marker for references to Git URL layouts: ##GITNAMESPACE##
68 @call_with(user=REQUEST_USER)
69 @operation_parameters(
70diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py
71index 94d6538..60dff43 100644
72--- a/lib/lp/code/model/githosting.py
73+++ b/lib/lp/code/model/githosting.py
74@@ -1,4 +1,4 @@
75-# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
76+# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
77 # GNU Affero General Public License version 3 (see the file LICENSE).
78
79 """Communication with the Git hosting service."""
80@@ -90,13 +90,20 @@ class GitHostingClient:
81 def _delete(self, path, **kwargs):
82 return self._request("delete", path, **kwargs)
83
84- def create(self, path, clone_from=None):
85+ def create(self, path, clone_from=None, async_create=False):
86 """See `IGitHostingClient`."""
87 try:
88 if clone_from:
89 request = {"repo_path": path, "clone_from": clone_from}
90 else:
91 request = {"repo_path": path}
92+ if async_create:
93+ # XXX pappacena 2020-07-02: async forces to clone_refs
94+ # because it's only used in situations where this is
95+ # desirable for now. We might need to add "clone_refs" as
96+ # parameter in the future.
97+ request['async'] = True
98+ request['clone_refs'] = clone_from is not None
99 self._post("/repo", json=request)
100 except requests.RequestException as e:
101 raise GitRepositoryCreationFault(
102diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py
103index 3c041da..ab37d2b 100644
104--- a/lib/lp/code/model/gitjob.py
105+++ b/lib/lp/code/model/gitjob.py
106@@ -1,4 +1,4 @@
107-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
108+# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
109 # GNU Affero General Public License version 3 (see the file LICENSE).
110
111 __metaclass__ = type
112@@ -12,6 +12,7 @@ __all__ = [
113 'ReclaimGitRepositorySpaceJob',
114 ]
115
116+
117 from lazr.delegates import delegate_to
118 from lazr.enum import (
119 DBEnumeratedType,
120diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py
121index f9ac2f5..22e9d8f 100644
122--- a/lib/lp/code/model/gitnamespace.py
123+++ b/lib/lp/code/model/gitnamespace.py
124@@ -14,6 +14,7 @@ __all__ = [
125
126 from lazr.lifecycle.event import ObjectCreatedEvent
127 from storm.locals import And
128+import transaction
129 from zope.component import getUtility
130 from zope.event import notify
131 from zope.interface import implementer
132@@ -33,6 +34,7 @@ from lp.code.enums import (
133 BranchSubscriptionDiffSize,
134 BranchSubscriptionNotificationLevel,
135 CodeReviewNotificationLevel,
136+ GitRepositoryStatus,
137 )
138 from lp.code.errors import (
139 GitDefaultConflict,
140@@ -75,7 +77,8 @@ class _BaseGitNamespace:
141 reviewer=None, information_type=None,
142 date_created=DEFAULT, description=None,
143 target_default=False, owner_default=False,
144- with_hosting=False, status=None):
145+ with_hosting=False, async_hosting=False,
146+ status=GitRepositoryStatus.AVAILABLE):
147 """See `IGitNamespace`."""
148 repository_set = getUtility(IGitRepositorySet)
149
150@@ -119,11 +122,20 @@ class _BaseGitNamespace:
151
152 # Flush to make sure that repository.id is populated.
153 IStore(repository).flush()
154+ if async_hosting:
155+ # If we are going to run async creation, we need to be sure
156+ # the transaction is committed.
157+ # Async creation will run a callback on Launchpad, and if
158+ # the creation is quick enough, it might try to confirm on
159+ # Launchpad (in another transaction) the creation of this
160+ # repo before this transaction is actually committed.
161+ transaction.commit()
162 assert repository.id is not None
163
164 clone_from_repository = repository.getClonedFrom()
165 repository._createOnHostingService(
166- clone_from_repository=clone_from_repository)
167+ clone_from_repository=clone_from_repository,
168+ async_create=async_hosting)
169
170 return repository
171
172diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
173index 3d2408e..726e9f1 100644
174--- a/lib/lp/code/model/gitrepository.py
175+++ b/lib/lp/code/model/gitrepository.py
176@@ -359,7 +359,8 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
177 self.owner_default = False
178 self.target_default = False
179
180- def _createOnHostingService(self, clone_from_repository=None):
181+ def _createOnHostingService(
182+ self, clone_from_repository=None, async_create=False):
183 """Create this repository on the hosting service."""
184 hosting_path = self.getInternalPath()
185 if clone_from_repository is not None:
186@@ -367,7 +368,8 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
187 else:
188 clone_from_path = None
189 getUtility(IGitHostingClient).create(
190- hosting_path, clone_from=clone_from_path)
191+ hosting_path, clone_from=clone_from_path,
192+ async_create=async_create)
193
194 def getClonedFrom(self):
195 """See `IGitRepository`"""
196@@ -1729,13 +1731,27 @@ class GitRepositorySet:
197
198 def new(self, repository_type, registrant, owner, target, name,
199 information_type=None, date_created=DEFAULT, description=None,
200- with_hosting=False):
201+ with_hosting=False, async_hosting=False,
202+ status=GitRepositoryStatus.AVAILABLE):
203 """See `IGitRepositorySet`."""
204 namespace = get_git_namespace(target, owner)
205 return namespace.createRepository(
206 repository_type, registrant, name,
207 information_type=information_type, date_created=date_created,
208- description=description, with_hosting=with_hosting)
209+ description=description, with_hosting=with_hosting,
210+ async_hosting=async_hosting, status=status)
211+
212+ def fork(self, origin, requester, new_owner):
213+ namespace = get_git_namespace(origin.target, new_owner)
214+ name = namespace.findUnusedName(origin.name)
215+ repository = self.new(
216+ repository_type=GitRepositoryType.HOSTED,
217+ registrant=requester, owner=new_owner, target=origin.target,
218+ name=name, information_type=origin.information_type,
219+ date_created=UTC_NOW, description=origin.description,
220+ with_hosting=True, async_hosting=True,
221+ status=GitRepositoryStatus.CREATING)
222+ return repository
223
224 def getByPath(self, user, path):
225 """See `IGitRepositorySet`."""
226diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
227index d966dbe..b35fdd7 100644
228--- a/lib/lp/code/model/tests/test_githosting.py
229+++ b/lib/lp/code/model/tests/test_githosting.py
230@@ -2,7 +2,7 @@
231 # NOTE: The first line above must stay first; do not move the copyright
232 # notice to the top. See http://www.python.org/dev/peps/pep-0263/.
233 #
234-# Copyright 2016-2019 Canonical Ltd. This software is licensed under the
235+# Copyright 2016-2020 Canonical Ltd. This software is licensed under the
236 # GNU Affero General Public License version 3 (see the file LICENSE).
237
238 """Unit tests for `GitHostingClient`.
239@@ -23,7 +23,6 @@ from lazr.restful.utils import get_current_browser_request
240 import responses
241 from six.moves.urllib.parse import (
242 parse_qsl,
243- urljoin,
244 urlsplit,
245 )
246 from testtools.matchers import (
247@@ -130,6 +129,15 @@ class TestGitHostingClient(TestCase):
248 "repo", method="POST",
249 json_data={"repo_path": "123", "clone_from": "122"})
250
251+ def test_create_async(self):
252+ with self.mockRequests("POST"):
253+ self.client.create("123", clone_from="122", async_create=True)
254+ self.assertRequest(
255+ "repo", method="POST",
256+ json_data={
257+ "repo_path": "123", "clone_from": "122", "async": True,
258+ "clone_refs": True})
259+
260 def test_create_failure(self):
261 with self.mockRequests("POST", status=400):
262 self.assertRaisesWithContent(
263diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py
264index 5964944..d4bfe20 100644
265--- a/lib/lp/code/model/tests/test_gitjob.py
266+++ b/lib/lp/code/model/tests/test_gitjob.py
267@@ -1,4 +1,4 @@
268-# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
269+# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
270 # GNU Affero General Public License version 3 (see the file LICENSE).
271
272 """Tests for `GitJob`s."""
273diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
274index 62bf865..7888d2f 100644
275--- a/lib/lp/code/model/tests/test_gitrepository.py
276+++ b/lib/lp/code/model/tests/test_gitrepository.py
277@@ -2729,6 +2729,38 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
278 self.assertFalse(snap.is_stale)
279
280
281+class TestGitRepositoryFork(TestCaseWithFactory):
282+
283+ layer = DatabaseFunctionalLayer
284+
285+ def setUp(self):
286+ super(TestGitRepositoryFork, self).setUp()
287+ self.hosting_fixture = self.useFixture(GitHostingFixture())
288+
289+ def test_fork(self):
290+ repo = self.factory.makeGitRepository()
291+ another_person = self.factory.makePerson()
292+ another_team = self.factory.makeTeam(members=[another_person])
293+
294+ forked_repo = getUtility(IGitRepositorySet).fork(
295+ repo, another_person, another_team)
296+ self.assertEqual(another_team, forked_repo.owner)
297+ self.assertEqual(another_person, forked_repo.registrant)
298+ self.assertEqual(1, self.hosting_fixture.create.call_count)
299+
300+ def test_fork_same_name(self):
301+ repo = self.factory.makeGitRepository()
302+
303+ person = self.factory.makePerson()
304+ same_name_repo = self.factory.makeGitRepository(
305+ owner=person, registrant=person,
306+ name=repo.name, target=repo.target)
307+
308+ forked_repo = getUtility(IGitRepositorySet).fork(repo, person, person)
309+ self.assertEqual(forked_repo.target, repo.target)
310+ self.assertEqual(forked_repo.name, "%s-1" % same_name_repo.name)
311+
312+
313 class TestGitRepositoryDetectMerges(TestCaseWithFactory):
314
315 layer = ZopelessDatabaseLayer
316@@ -3271,7 +3303,8 @@ class TestGitRepositorySet(TestCaseWithFactory):
317 self.assertThat(repository, MatchesStructure.byEquality(
318 registrant=owner, owner=owner, target=target, name=name))
319 self.assertEqual(
320- [((repository.getInternalPath(),), {"clone_from": None})],
321+ [((repository.getInternalPath(),),
322+ {'async_create': False, "clone_from": None})],
323 hosting_fixture.create.calls)
324
325 def test_provides_IGitRepositorySet(self):
326diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py
327index a4f8416..4ef843d 100644
328--- a/lib/lp/code/tests/helpers.py
329+++ b/lib/lp/code/tests/helpers.py
330@@ -355,7 +355,7 @@ class GitHostingFixture(fixtures.Fixture):
331 merges=None, blob=None, disable_memcache=True):
332 self.create = FakeMethod()
333 self.getProperties = FakeMethod(
334- result={"default_branch": default_branch})
335+ result={"default_branch": default_branch, "is_available": True})
336 self.setProperties = FakeMethod()
337 self.getRefs = FakeMethod(result=({} if refs is None else refs))
338 self.getCommits = FakeMethod(
339diff --git a/lib/lp/code/xmlrpc/git.py b/lib/lp/code/xmlrpc/git.py
340index 0c3a2f1..c148acc 100644
341--- a/lib/lp/code/xmlrpc/git.py
342+++ b/lib/lp/code/xmlrpc/git.py
343@@ -627,6 +627,15 @@ class GitAPI(LaunchpadXMLRPCView):
344 if requester == LAUNCHPAD_ANONYMOUS:
345 requester = None
346
347+ if naked_repo.status != GitRepositoryStatus.CREATING:
348+ raise faults.Unauthorized()
349+
350+ if requester == LAUNCHPAD_SERVICES and "macaroon" not in auth_params:
351+ # For repo creation management operations, we trust
352+ # LAUNCHPAD_SERVICES, since it should be just an internal call
353+ # to confirm/abort repository creation.
354+ return
355+
356 verified = self._verifyAuthParams(requester, repository, auth_params)
357 if verified is not None and verified.user is NO_USER:
358 # For internal-services authentication, we check if its using a
359@@ -642,9 +651,6 @@ class GitAPI(LaunchpadXMLRPCView):
360 if requester != naked_repo.registrant:
361 raise faults.Unauthorized()
362
363- if naked_repo.status != GitRepositoryStatus.CREATING:
364- raise faults.Unauthorized()
365-
366 def _confirmRepoCreation(self, requester, translated_path, auth_params):
367 naked_repo = removeSecurityProxy(
368 getUtility(IGitLookup).getByHostingPath(translated_path))
369diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
370index c3b8126..9fb9b2e 100644
371--- a/lib/lp/code/xmlrpc/tests/test_git.py
372+++ b/lib/lp/code/xmlrpc/tests/test_git.py
373@@ -401,9 +401,10 @@ class TestGitAPIMixin:
374 expected_status = GitRepositoryStatus.AVAILABLE
375 expected_hosting_calls = 1
376 expected_hosting_call_args = [(repository.getInternalPath(),)]
377- expected_hosting_call_kwargs = [
378- {"clone_from": (cloned_from.getInternalPath()
379- if cloned_from else None)}]
380+ expected_hosting_call_kwargs = [{
381+ "clone_from": (cloned_from.getInternalPath()
382+ if cloned_from else None),
383+ "async_create": False}]
384
385 self.assertEqual(GitRepositoryType.HOSTED, repository.repository_type)
386 self.assertEqual(expected_translation, translation)
387@@ -780,6 +781,12 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
388 repo.status = GitRepositoryStatus.CREATING
389 self.assertConfirmsRepoCreation(owner, repo)
390
391+ def test_launchpad_service_confirm_git_repository_creation(self):
392+ owner = self.factory.makePerson()
393+ repo = removeSecurityProxy(self.factory.makeGitRepository(owner=owner))
394+ repo.status = GitRepositoryStatus.CREATING
395+ self.assertConfirmsRepoCreation(LAUNCHPAD_SERVICES, repo)
396+
397 def test_only_requester_can_confirm_git_repository_creation(self):
398 requester = self.factory.makePerson()
399 repo = removeSecurityProxy(self.factory.makeGitRepository())
400@@ -961,6 +968,12 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
401 repo.status = GitRepositoryStatus.CREATING
402 self.assertAbortsRepoCreation(requester, repo)
403
404+ def test_launchpad_service_abort_git_repository_creation(self):
405+ owner = self.factory.makePerson()
406+ repo = removeSecurityProxy(self.factory.makeGitRepository(owner=owner))
407+ repo.status = GitRepositoryStatus.CREATING
408+ self.assertAbortsRepoCreation(LAUNCHPAD_SERVICES, repo)
409+
410 def test_only_requester_can_abort_git_repository_creation(self):
411 requester = self.factory.makePerson()
412 repo = removeSecurityProxy(self.factory.makeGitRepository())