Merge ~pappacena/launchpad:git-fork-backend into launchpad:master
- Git
- lp:~pappacena/launchpad
- git-fork-backend
- Merge into 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) |
Related bugs: |
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
Description of the change
To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote : | # |
Revision history for this message
Colin Watson (cjwatson) wrote : | # |
I haven't completely gone through this yet, but here are a few high-level/
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
1 | diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py |
2 | index 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): |
28 | diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py |
29 | index 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?""" |
51 | diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py |
52 | index 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( |
70 | diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py |
71 | index 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( |
102 | diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py |
103 | index 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, |
120 | diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py |
121 | index 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 | |
172 | diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py |
173 | index 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`.""" |
226 | diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py |
227 | index 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( |
263 | diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py |
264 | index 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.""" |
273 | diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py |
274 | index 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): |
326 | diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py |
327 | index 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( |
339 | diff --git a/lib/lp/code/xmlrpc/git.py b/lib/lp/code/xmlrpc/git.py |
340 | index 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)) |
369 | diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py |
370 | index 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()) |
This MP depends on https:/ /code.launchpad .net/~pappacena /turnip/ +git/turnip/ +merge/ 386461 being approved and landed in production.