Merge lp:~cjwatson/launchpad/export-git-repositories-new into lp:launchpad
- export-git-repositories-new
- Merge into devel
Status: | Rejected |
---|---|
Rejected by: | Colin Watson |
Proposed branch: | lp:~cjwatson/launchpad/export-git-repositories-new |
Merge into: | lp:launchpad |
Diff against target: |
496 lines (+185/-80) 9 files modified
lib/lp/code/errors.py (+5/-1) lib/lp/code/interfaces/gitnamespace.py (+6/-2) lib/lp/code/interfaces/gitrepository.py (+15/-2) lib/lp/code/model/githosting.py (+2/-2) lib/lp/code/model/gitnamespace.py (+42/-2) lib/lp/code/model/gitrepository.py (+21/-6) lib/lp/code/model/tests/test_gitrepository.py (+66/-0) lib/lp/code/xmlrpc/git.py (+27/-64) lib/lp/code/xmlrpc/tests/test_git.py (+1/-1) |
To merge this branch: | bzr merge lp:~cjwatson/launchpad/export-git-repositories-new |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Launchpad code reviewers | Pending | ||
Review via email: mp+373267@code.launchpad.net |
Commit message
Export IGitRepositoryS
Description of the change
For example, this allows snapcraft to create a temporary repository, issue an access token to it, and push code to it, all without needing to configure an SSH key.
In order to make this possible, I had to do some preliminary refactoring to push on-disk repository creation down from the XML-RPC endpoint to the model.
Colin Watson (cjwatson) wrote : | # |
Unmerged revisions
- 19055. By Colin Watson
-
Fix GitRepositorySe
t.new, and export it on the webservice. This was previously untested, and had been broken since r18222.
Now that namespace.
createRepositor y can create the repository on the hosting
service, we can safely export this to create a bare repository via the API.
For example, this allows snapcraft to create a temporary repository, issue
an access token to it, and push code to it, all without needing to configure
an SSH key. - 19054. By Colin Watson
-
Push Git hosting creation down from XML-RPC endpoint to model.
Having the creation of the actual repository on disk be done by the XML-RPC
endpoint makes it difficult to expose other methods of creating
repositories.I rearranged how target and owner defaults are set. We have to create the
repository on the hosting service as the last step to avoid problems with
rolling back transactions, so pushing this part down to the model also
requires pushing down target/owner default handling, and the callback
mechanism previously in place wasn't very suited to that.I had to adjust the permission check in
GitRepositorySet.setDefaultRep ository slightly, because pushing down
target/owner default handling is easier if the Unauthorized exception
message is more accurate. setDefaultRepositoryForOwner already raised
exceptions with accurate messages.
Preview Diff
1 | === modified file 'lib/lp/code/errors.py' | |||
2 | --- lib/lp/code/errors.py 2018-08-17 11:46:36 +0000 | |||
3 | +++ lib/lp/code/errors.py 2019-09-26 15:27:30 +0000 | |||
4 | @@ -1,4 +1,4 @@ | |||
6 | 1 | # Copyright 2009-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2019 Canonical Ltd. This software is licensed under the |
7 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
8 | 3 | 3 | ||
9 | 4 | """Errors used in the lp/code modules.""" | 4 | """Errors used in the lp/code modules.""" |
10 | @@ -432,6 +432,10 @@ | |||
11 | 432 | class GitRepositoryCreationFault(Exception): | 432 | class GitRepositoryCreationFault(Exception): |
12 | 433 | """Raised when there is a hosting fault creating a Git repository.""" | 433 | """Raised when there is a hosting fault creating a Git repository.""" |
13 | 434 | 434 | ||
14 | 435 | def __init__(self, message, path): | ||
15 | 436 | super(GitRepositoryCreationFault, self).__init__(message) | ||
16 | 437 | self.path = path | ||
17 | 438 | |||
18 | 435 | 439 | ||
19 | 436 | class GitRepositoryScanFault(Exception): | 440 | class GitRepositoryScanFault(Exception): |
20 | 437 | """Raised when there is a fault scanning a repository.""" | 441 | """Raised when there is a fault scanning a repository.""" |
21 | 438 | 442 | ||
22 | === modified file 'lib/lp/code/interfaces/gitnamespace.py' | |||
23 | --- lib/lp/code/interfaces/gitnamespace.py 2018-07-10 11:26:57 +0000 | |||
24 | +++ lib/lp/code/interfaces/gitnamespace.py 2019-09-26 15:27:30 +0000 | |||
25 | @@ -1,4 +1,4 @@ | |||
27 | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
28 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
29 | 3 | 3 | ||
30 | 4 | """Interface for a Git repository namespace.""" | 4 | """Interface for a Git repository namespace.""" |
31 | @@ -32,10 +32,14 @@ | |||
32 | 32 | name = Attribute( | 32 | name = Attribute( |
33 | 33 | "The name of the namespace. This is prepended to the repository name.") | 33 | "The name of the namespace. This is prepended to the repository name.") |
34 | 34 | 34 | ||
35 | 35 | owner = Attribute("The `IPerson` who owns this namespace.") | ||
36 | 36 | |||
37 | 35 | target = Attribute("The `IHasGitRepositories` for this namespace.") | 37 | target = Attribute("The `IHasGitRepositories` for this namespace.") |
38 | 36 | 38 | ||
39 | 37 | def createRepository(repository_type, registrant, name, | 39 | def createRepository(repository_type, registrant, name, |
41 | 38 | information_type=None, date_created=None): | 40 | information_type=None, date_created=None, |
42 | 41 | target_default=False, owner_default=False, | ||
43 | 42 | with_hosting=False): | ||
44 | 39 | """Create and return an `IGitRepository` in this namespace.""" | 43 | """Create and return an `IGitRepository` in this namespace.""" |
45 | 40 | 44 | ||
46 | 41 | def isNameUsed(name): | 45 | def isNameUsed(name): |
47 | 42 | 46 | ||
48 | === modified file 'lib/lp/code/interfaces/gitrepository.py' | |||
49 | --- lib/lp/code/interfaces/gitrepository.py 2019-05-11 11:16:16 +0000 | |||
50 | +++ lib/lp/code/interfaces/gitrepository.py 2019-09-26 15:27:30 +0000 | |||
51 | @@ -25,6 +25,7 @@ | |||
52 | 25 | export_as_webservice_collection, | 25 | export_as_webservice_collection, |
53 | 26 | export_as_webservice_entry, | 26 | export_as_webservice_entry, |
54 | 27 | export_destructor_operation, | 27 | export_destructor_operation, |
55 | 28 | export_factory_operation, | ||
56 | 28 | export_operation_as, | 29 | export_operation_as, |
57 | 29 | export_read_operation, | 30 | export_read_operation, |
58 | 30 | export_write_operation, | 31 | export_write_operation, |
59 | @@ -927,10 +928,21 @@ | |||
60 | 927 | 928 | ||
61 | 928 | export_as_webservice_collection(IGitRepository) | 929 | export_as_webservice_collection(IGitRepository) |
62 | 929 | 930 | ||
65 | 930 | def new(registrant, owner, target, name, information_type=None, | 931 | @call_with( |
66 | 931 | date_created=None): | 932 | repository_type=GitRepositoryType.HOSTED, |
67 | 933 | registrant=REQUEST_USER, | ||
68 | 934 | with_hosting=True) | ||
69 | 935 | @operation_parameters( | ||
70 | 936 | information_type=copy_field( | ||
71 | 937 | IGitRepositoryView["information_type"], required=False)) | ||
72 | 938 | @export_factory_operation(IGitRepository, ["owner", "target", "name"]) | ||
73 | 939 | @operation_for_version("devel") | ||
74 | 940 | def new(repository_type, registrant, owner, target, name, | ||
75 | 941 | information_type=None, date_created=None, with_hosting=False): | ||
76 | 932 | """Create a Git repository and return it. | 942 | """Create a Git repository and return it. |
77 | 933 | 943 | ||
78 | 944 | :param repository_type: The `GitRepositoryType` of the new | ||
79 | 945 | repository. | ||
80 | 934 | :param registrant: The `IPerson` who registered the new repository. | 946 | :param registrant: The `IPerson` who registered the new repository. |
81 | 935 | :param owner: The `IPerson` who owns the new repository. | 947 | :param owner: The `IPerson` who owns the new repository. |
82 | 936 | :param target: The `IProduct`, `IDistributionSourcePackage`, or | 948 | :param target: The `IProduct`, `IDistributionSourcePackage`, or |
83 | @@ -939,6 +951,7 @@ | |||
84 | 939 | :param information_type: Set the repository's information type to | 951 | :param information_type: Set the repository's information type to |
85 | 940 | one different from the target's default. The type must conform | 952 | one different from the target's default. The type must conform |
86 | 941 | to the target's code sharing policy. (optional) | 953 | to the target's code sharing policy. (optional) |
87 | 954 | :param with_hosting: Create the repository on the hosting service. | ||
88 | 942 | """ | 955 | """ |
89 | 943 | 956 | ||
90 | 944 | # Marker for references to Git URL layouts: ##GITNAMESPACE## | 957 | # Marker for references to Git URL layouts: ##GITNAMESPACE## |
91 | 945 | 958 | ||
92 | === modified file 'lib/lp/code/model/githosting.py' | |||
93 | --- lib/lp/code/model/githosting.py 2018-11-22 16:35:28 +0000 | |||
94 | +++ lib/lp/code/model/githosting.py 2019-09-26 15:27:30 +0000 | |||
95 | @@ -1,4 +1,4 @@ | |||
97 | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
98 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
99 | 3 | 3 | ||
100 | 4 | """Communication with the Git hosting service.""" | 4 | """Communication with the Git hosting service.""" |
101 | @@ -98,7 +98,7 @@ | |||
102 | 98 | self._post("/repo", json=request) | 98 | self._post("/repo", json=request) |
103 | 99 | except requests.RequestException as e: | 99 | except requests.RequestException as e: |
104 | 100 | raise GitRepositoryCreationFault( | 100 | raise GitRepositoryCreationFault( |
106 | 101 | "Failed to create Git repository: %s" % unicode(e)) | 101 | "Failed to create Git repository: %s" % unicode(e), path) |
107 | 102 | 102 | ||
108 | 103 | def getProperties(self, path): | 103 | def getProperties(self, path): |
109 | 104 | """See `IGitHostingClient`.""" | 104 | """See `IGitHostingClient`.""" |
110 | 105 | 105 | ||
111 | === modified file 'lib/lp/code/model/gitnamespace.py' | |||
112 | --- lib/lp/code/model/gitnamespace.py 2018-07-10 16:36:54 +0000 | |||
113 | +++ lib/lp/code/model/gitnamespace.py 2019-09-26 15:27:30 +0000 | |||
114 | @@ -1,4 +1,4 @@ | |||
116 | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
117 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
118 | 3 | 3 | ||
119 | 4 | """Implementations of `IGitNamespace`.""" | 4 | """Implementations of `IGitNamespace`.""" |
120 | @@ -39,6 +39,7 @@ | |||
121 | 39 | GitRepositoryCreatorNotMemberOfOwnerTeam, | 39 | GitRepositoryCreatorNotMemberOfOwnerTeam, |
122 | 40 | GitRepositoryCreatorNotOwner, | 40 | GitRepositoryCreatorNotOwner, |
123 | 41 | GitRepositoryExists, | 41 | GitRepositoryExists, |
124 | 42 | GitTargetError, | ||
125 | 42 | ) | 43 | ) |
126 | 43 | from lp.code.interfaces.gitcollection import IAllGitRepositories | 44 | from lp.code.interfaces.gitcollection import IAllGitRepositories |
127 | 44 | from lp.code.interfaces.gitnamespace import ( | 45 | from lp.code.interfaces.gitnamespace import ( |
128 | @@ -72,8 +73,11 @@ | |||
129 | 72 | 73 | ||
130 | 73 | def createRepository(self, repository_type, registrant, name, | 74 | def createRepository(self, repository_type, registrant, name, |
131 | 74 | reviewer=None, information_type=None, | 75 | reviewer=None, information_type=None, |
133 | 75 | date_created=DEFAULT, description=None): | 76 | date_created=DEFAULT, description=None, |
134 | 77 | target_default=False, owner_default=False, | ||
135 | 78 | with_hosting=False): | ||
136 | 76 | """See `IGitNamespace`.""" | 79 | """See `IGitNamespace`.""" |
137 | 80 | repository_set = getUtility(IGitRepositorySet) | ||
138 | 77 | 81 | ||
139 | 78 | self.validateRegistrant(registrant) | 82 | self.validateRegistrant(registrant) |
140 | 79 | self.validateRepositoryName(name) | 83 | self.validateRepositoryName(name) |
141 | @@ -102,6 +106,42 @@ | |||
142 | 102 | 106 | ||
143 | 103 | notify(ObjectCreatedEvent(repository)) | 107 | notify(ObjectCreatedEvent(repository)) |
144 | 104 | 108 | ||
145 | 109 | if target_default: | ||
146 | 110 | repository_set.setDefaultRepository(self.target, repository) | ||
147 | 111 | if owner_default: | ||
148 | 112 | repository_set.setDefaultRepositoryForOwner( | ||
149 | 113 | self.owner, self.target, repository, registrant) | ||
150 | 114 | |||
151 | 115 | if with_hosting: | ||
152 | 116 | # Ask the hosting service to create the repository on disk. Do | ||
153 | 117 | # this as late as possible, since if this succeeds it can't | ||
154 | 118 | # easily be rolled back with the rest of the transaction. | ||
155 | 119 | |||
156 | 120 | # Flush to make sure that repository.id is populated. | ||
157 | 121 | IStore(repository).flush() | ||
158 | 122 | assert repository.id is not None | ||
159 | 123 | |||
160 | 124 | # If repository has target_default, clone from default. | ||
161 | 125 | clone_from_repository = None | ||
162 | 126 | try: | ||
163 | 127 | default = repository_set.getDefaultRepository( | ||
164 | 128 | repository.target) | ||
165 | 129 | if default is not None and default.visibleByUser(registrant): | ||
166 | 130 | clone_from_repository = default | ||
167 | 131 | else: | ||
168 | 132 | default = repository_set.getDefaultRepositoryForOwner( | ||
169 | 133 | repository.owner, repository.target) | ||
170 | 134 | if (default is not None and | ||
171 | 135 | default.visibleByUser(registrant)): | ||
172 | 136 | clone_from_repository = default | ||
173 | 137 | except GitTargetError: | ||
174 | 138 | pass # Ignore Personal repositories. | ||
175 | 139 | if clone_from_repository == repository: | ||
176 | 140 | clone_from_repository = None | ||
177 | 141 | |||
178 | 142 | repository._createOnHostingService( | ||
179 | 143 | clone_from_repository=clone_from_repository) | ||
180 | 144 | |||
181 | 105 | return repository | 145 | return repository |
182 | 106 | 146 | ||
183 | 107 | def isNameUsed(self, repository_name): | 147 | def isNameUsed(self, repository_name): |
184 | 108 | 148 | ||
185 | === modified file 'lib/lp/code/model/gitrepository.py' | |||
186 | --- lib/lp/code/model/gitrepository.py 2019-09-16 09:05:01 +0000 | |||
187 | +++ lib/lp/code/model/gitrepository.py 2019-09-26 15:27:30 +0000 | |||
188 | @@ -197,7 +197,7 @@ | |||
189 | 197 | cachedproperty, | 197 | cachedproperty, |
190 | 198 | get_property_cache, | 198 | get_property_cache, |
191 | 199 | ) | 199 | ) |
193 | 200 | from lp.services.webapp.authorization import available_with_permission | 200 | from lp.services.webapp.authorization import check_permission |
194 | 201 | from lp.services.webapp.interfaces import ILaunchBag | 201 | from lp.services.webapp.interfaces import ILaunchBag |
195 | 202 | from lp.services.webhooks.interfaces import IWebhookSet | 202 | from lp.services.webhooks.interfaces import IWebhookSet |
196 | 203 | from lp.services.webhooks.model import WebhookTargetMixin | 203 | from lp.services.webhooks.model import WebhookTargetMixin |
197 | @@ -342,6 +342,16 @@ | |||
198 | 342 | self.owner_default = False | 342 | self.owner_default = False |
199 | 343 | self.target_default = False | 343 | self.target_default = False |
200 | 344 | 344 | ||
201 | 345 | def _createOnHostingService(self, clone_from_repository=None): | ||
202 | 346 | """Create this repository on the hosting service.""" | ||
203 | 347 | hosting_path = self.getInternalPath() | ||
204 | 348 | if clone_from_repository is not None: | ||
205 | 349 | clone_from_path = clone_from_repository.getInternalPath() | ||
206 | 350 | else: | ||
207 | 351 | clone_from_path = None | ||
208 | 352 | getUtility(IGitHostingClient).create( | ||
209 | 353 | hosting_path, clone_from=clone_from_path) | ||
210 | 354 | |||
211 | 345 | @property | 355 | @property |
212 | 346 | def valid_webhook_event_types(self): | 356 | def valid_webhook_event_types(self): |
213 | 347 | return ["git:push:0.1", "merge-proposal:0.1"] | 357 | return ["git:push:0.1", "merge-proposal:0.1"] |
214 | @@ -1648,13 +1658,15 @@ | |||
215 | 1648 | class GitRepositorySet: | 1658 | class GitRepositorySet: |
216 | 1649 | """See `IGitRepositorySet`.""" | 1659 | """See `IGitRepositorySet`.""" |
217 | 1650 | 1660 | ||
220 | 1651 | def new(self, registrant, owner, target, name, information_type=None, | 1661 | def new(self, repository_type, registrant, owner, target, name, |
221 | 1652 | date_created=DEFAULT, description=None): | 1662 | information_type=None, date_created=DEFAULT, description=None, |
222 | 1663 | with_hosting=False): | ||
223 | 1653 | """See `IGitRepositorySet`.""" | 1664 | """See `IGitRepositorySet`.""" |
224 | 1654 | namespace = get_git_namespace(target, owner) | 1665 | namespace = get_git_namespace(target, owner) |
225 | 1655 | return namespace.createRepository( | 1666 | return namespace.createRepository( |
228 | 1656 | registrant, name, information_type=information_type, | 1667 | repository_type, registrant, name, |
229 | 1657 | date_created=date_created, description=description) | 1668 | information_type=information_type, date_created=date_created, |
230 | 1669 | description=description, with_hosting=with_hosting) | ||
231 | 1658 | 1670 | ||
232 | 1659 | def getByPath(self, user, path): | 1671 | def getByPath(self, user, path): |
233 | 1660 | """See `IGitRepositorySet`.""" | 1672 | """See `IGitRepositorySet`.""" |
234 | @@ -1724,13 +1736,16 @@ | |||
235 | 1724 | "Personal repositories cannot be defaults for any target.") | 1736 | "Personal repositories cannot be defaults for any target.") |
236 | 1725 | return IStore(GitRepository).find(GitRepository, *clauses).one() | 1737 | return IStore(GitRepository).find(GitRepository, *clauses).one() |
237 | 1726 | 1738 | ||
238 | 1727 | @available_with_permission('launchpad.Edit', 'target') | ||
239 | 1728 | def setDefaultRepository(self, target, repository): | 1739 | def setDefaultRepository(self, target, repository): |
240 | 1729 | """See `IGitRepositorySet`.""" | 1740 | """See `IGitRepositorySet`.""" |
241 | 1730 | if IPerson.providedBy(target): | 1741 | if IPerson.providedBy(target): |
242 | 1731 | raise GitTargetError( | 1742 | raise GitTargetError( |
243 | 1732 | "Cannot set a default Git repository for a person, only " | 1743 | "Cannot set a default Git repository for a person, only " |
244 | 1733 | "for a project or a package.") | 1744 | "for a project or a package.") |
245 | 1745 | if not check_permission("launchpad.Edit", target): | ||
246 | 1746 | raise Unauthorized( | ||
247 | 1747 | "You cannot set the default Git repository for %s." % | ||
248 | 1748 | target.display_name) | ||
249 | 1734 | if repository is not None and repository.target != target: | 1749 | if repository is not None and repository.target != target: |
250 | 1735 | raise GitTargetError( | 1750 | raise GitTargetError( |
251 | 1736 | "Cannot set default Git repository to one attached to " | 1751 | "Cannot set default Git repository to one attached to " |
252 | 1737 | 1752 | ||
253 | === modified file 'lib/lp/code/model/tests/test_gitrepository.py' | |||
254 | --- lib/lp/code/model/tests/test_gitrepository.py 2019-09-16 09:05:01 +0000 | |||
255 | +++ lib/lp/code/model/tests/test_gitrepository.py 2019-09-26 15:27:30 +0000 | |||
256 | @@ -26,6 +26,7 @@ | |||
257 | 26 | from storm.store import Store | 26 | from storm.store import Store |
258 | 27 | from testtools.matchers import ( | 27 | from testtools.matchers import ( |
259 | 28 | AnyMatch, | 28 | AnyMatch, |
260 | 29 | ContainsDict, | ||
261 | 29 | EndsWith, | 30 | EndsWith, |
262 | 30 | Equals, | 31 | Equals, |
263 | 31 | Is, | 32 | Is, |
264 | @@ -2990,6 +2991,35 @@ | |||
265 | 2990 | super(TestGitRepositorySet, self).setUp() | 2991 | super(TestGitRepositorySet, self).setUp() |
266 | 2991 | self.repository_set = getUtility(IGitRepositorySet) | 2992 | self.repository_set = getUtility(IGitRepositorySet) |
267 | 2992 | 2993 | ||
268 | 2994 | def test_new(self): | ||
269 | 2995 | # By default, GitRepositorySet.new creates a new repository in the | ||
270 | 2996 | # database but not on the hosting service. | ||
271 | 2997 | hosting_fixture = self.useFixture(GitHostingFixture()) | ||
272 | 2998 | owner = self.factory.makePerson() | ||
273 | 2999 | target = self.factory.makeProduct() | ||
274 | 3000 | name = self.factory.getUniqueUnicode() | ||
275 | 3001 | repository = self.repository_set.new( | ||
276 | 3002 | GitRepositoryType.HOSTED, owner, owner, target, name) | ||
277 | 3003 | self.assertThat(repository, MatchesStructure.byEquality( | ||
278 | 3004 | registrant=owner, owner=owner, target=target, name=name)) | ||
279 | 3005 | self.assertEqual(0, hosting_fixture.create.call_count) | ||
280 | 3006 | |||
281 | 3007 | def test_new_with_hosting(self): | ||
282 | 3008 | # GitRepositorySet.new(with_hosting=True) creates a new repository | ||
283 | 3009 | # in both the database and the hosting service. | ||
284 | 3010 | hosting_fixture = self.useFixture(GitHostingFixture()) | ||
285 | 3011 | owner = self.factory.makePerson() | ||
286 | 3012 | target = self.factory.makeProduct() | ||
287 | 3013 | name = self.factory.getUniqueUnicode() | ||
288 | 3014 | repository = self.repository_set.new( | ||
289 | 3015 | GitRepositoryType.HOSTED, owner, owner, target, name, | ||
290 | 3016 | with_hosting=True) | ||
291 | 3017 | self.assertThat(repository, MatchesStructure.byEquality( | ||
292 | 3018 | registrant=owner, owner=owner, target=target, name=name)) | ||
293 | 3019 | self.assertEqual( | ||
294 | 3020 | [((repository.getInternalPath(),), {"clone_from": None})], | ||
295 | 3021 | hosting_fixture.create.calls) | ||
296 | 3022 | |||
297 | 2993 | def test_provides_IGitRepositorySet(self): | 3023 | def test_provides_IGitRepositorySet(self): |
298 | 2994 | # GitRepositorySet instances provide IGitRepositorySet. | 3024 | # GitRepositorySet instances provide IGitRepositorySet. |
299 | 2995 | verifyObject(IGitRepositorySet, self.repository_set) | 3025 | verifyObject(IGitRepositorySet, self.repository_set) |
300 | @@ -3370,6 +3400,42 @@ | |||
301 | 3370 | "git+ssh://git.launchpad.test/~person/project/+git/repository", | 3400 | "git+ssh://git.launchpad.test/~person/project/+git/repository", |
302 | 3371 | repository["git_ssh_url"]) | 3401 | repository["git_ssh_url"]) |
303 | 3372 | 3402 | ||
304 | 3403 | def assertNewWorks(self, target_db): | ||
305 | 3404 | hosting_fixture = self.useFixture(GitHostingFixture()) | ||
306 | 3405 | if IPerson.providedBy(target_db): | ||
307 | 3406 | owner_db = target_db | ||
308 | 3407 | else: | ||
309 | 3408 | owner_db = self.factory.makePerson() | ||
310 | 3409 | owner_url = api_url(owner_db) | ||
311 | 3410 | target_url = api_url(target_db) | ||
312 | 3411 | name = "repository" | ||
313 | 3412 | webservice = webservice_for_person( | ||
314 | 3413 | owner_db, permission=OAuthPermission.WRITE_PUBLIC) | ||
315 | 3414 | webservice.default_api_version = "devel" | ||
316 | 3415 | response = webservice.named_post( | ||
317 | 3416 | "/+git", "new", owner=owner_url, target=target_url, name=name) | ||
318 | 3417 | self.assertEqual(201, response.status) | ||
319 | 3418 | repository = webservice.get(response.getHeader("Location")).jsonBody() | ||
320 | 3419 | self.assertThat(repository, ContainsDict({ | ||
321 | 3420 | "repository_type": Equals("Hosted"), | ||
322 | 3421 | "registrant_link": EndsWith(owner_url), | ||
323 | 3422 | "owner_link": EndsWith(owner_url), | ||
324 | 3423 | "target_link": EndsWith(target_url), | ||
325 | 3424 | "name": Equals(name), | ||
326 | 3425 | "owner_default": Is(False), | ||
327 | 3426 | "target_default": Is(False), | ||
328 | 3427 | })) | ||
329 | 3428 | self.assertEqual(1, hosting_fixture.create.call_count) | ||
330 | 3429 | |||
331 | 3430 | def test_new_project(self): | ||
332 | 3431 | self.assertNewWorks(self.factory.makeProduct()) | ||
333 | 3432 | |||
334 | 3433 | def test_new_package(self): | ||
335 | 3434 | self.assertNewWorks(self.factory.makeDistributionSourcePackage()) | ||
336 | 3435 | |||
337 | 3436 | def test_new_person(self): | ||
338 | 3437 | self.assertNewWorks(self.factory.makePerson()) | ||
339 | 3438 | |||
340 | 3373 | def assertGetRepositoriesWorks(self, target_db): | 3439 | def assertGetRepositoriesWorks(self, target_db): |
341 | 3374 | if IPerson.providedBy(target_db): | 3440 | if IPerson.providedBy(target_db): |
342 | 3375 | owner_db = target_db | 3441 | owner_db = target_db |
343 | 3376 | 3442 | ||
344 | === modified file 'lib/lp/code/xmlrpc/git.py' | |||
345 | --- lib/lp/code/xmlrpc/git.py 2019-09-10 09:58:52 +0000 | |||
346 | +++ lib/lp/code/xmlrpc/git.py 2019-09-26 15:27:30 +0000 | |||
347 | @@ -13,7 +13,6 @@ | |||
348 | 13 | from pymacaroons import Macaroon | 13 | from pymacaroons import Macaroon |
349 | 14 | import six | 14 | import six |
350 | 15 | from six.moves import xmlrpc_client | 15 | from six.moves import xmlrpc_client |
351 | 16 | from storm.store import Store | ||
352 | 17 | import transaction | 16 | import transaction |
353 | 18 | from zope.component import ( | 17 | from zope.component import ( |
354 | 19 | ComponentLookupError, | 18 | ComponentLookupError, |
355 | @@ -36,7 +35,6 @@ | |||
356 | 36 | GitRepositoryCreationFault, | 35 | GitRepositoryCreationFault, |
357 | 37 | GitRepositoryCreationForbidden, | 36 | GitRepositoryCreationForbidden, |
358 | 38 | GitRepositoryExists, | 37 | GitRepositoryExists, |
359 | 39 | GitTargetError, | ||
360 | 40 | InvalidNamespace, | 38 | InvalidNamespace, |
361 | 41 | ) | 39 | ) |
362 | 42 | from lp.code.interfaces.codehosting import ( | 40 | from lp.code.interfaces.codehosting import ( |
363 | @@ -44,7 +42,6 @@ | |||
364 | 44 | LAUNCHPAD_SERVICES, | 42 | LAUNCHPAD_SERVICES, |
365 | 45 | ) | 43 | ) |
366 | 46 | from lp.code.interfaces.gitapi import IGitAPI | 44 | from lp.code.interfaces.gitapi import IGitAPI |
367 | 47 | from lp.code.interfaces.githosting import IGitHostingClient | ||
368 | 48 | from lp.code.interfaces.gitjob import IGitRefScanJobSource | 45 | from lp.code.interfaces.gitjob import IGitRefScanJobSource |
369 | 49 | from lp.code.interfaces.gitlookup import ( | 46 | from lp.code.interfaces.gitlookup import ( |
370 | 50 | IGitLookup, | 47 | IGitLookup, |
371 | @@ -282,20 +279,15 @@ | |||
372 | 282 | if repository_name is None and not namespace.has_defaults: | 279 | if repository_name is None and not namespace.has_defaults: |
373 | 283 | raise InvalidNamespace(path) | 280 | raise InvalidNamespace(path) |
374 | 284 | if repository_name is None: | 281 | if repository_name is None: |
375 | 285 | def default_func(new_repository): | ||
376 | 286 | if owner is None: | ||
377 | 287 | self.repository_set.setDefaultRepository( | ||
378 | 288 | target, new_repository) | ||
379 | 289 | if (owner is not None or | ||
380 | 290 | self.repository_set.getDefaultRepositoryForOwner( | ||
381 | 291 | repository_owner, target) is None): | ||
382 | 292 | self.repository_set.setDefaultRepositoryForOwner( | ||
383 | 293 | repository_owner, target, new_repository, requester) | ||
384 | 294 | |||
385 | 295 | repository_name = namespace.findUnusedName(target.name) | 282 | repository_name = namespace.findUnusedName(target.name) |
387 | 296 | return namespace, repository_name, default_func | 283 | target_default = owner is None |
388 | 284 | owner_default = ( | ||
389 | 285 | owner is None or | ||
390 | 286 | self.repository_set.getDefaultRepositoryForOwner( | ||
391 | 287 | repository_owner, target) is None) | ||
392 | 288 | return namespace, repository_name, target_default, owner_default | ||
393 | 297 | else: | 289 | else: |
395 | 298 | return namespace, repository_name, None | 290 | return namespace, repository_name, False, False |
396 | 299 | 291 | ||
397 | 300 | def _reportError(self, path, exception, hosting_path=None): | 292 | def _reportError(self, path, exception, hosting_path=None): |
398 | 301 | properties = [ | 293 | properties = [ |
399 | @@ -310,7 +302,7 @@ | |||
400 | 310 | 302 | ||
401 | 311 | def _createRepository(self, requester, path, clone_from=None): | 303 | def _createRepository(self, requester, path, clone_from=None): |
402 | 312 | try: | 304 | try: |
404 | 313 | namespace, repository_name, default_func = ( | 305 | namespace, repository_name, target_default, owner_default = ( |
405 | 314 | self._getGitNamespaceExtras(path, requester)) | 306 | self._getGitNamespaceExtras(path, requester)) |
406 | 315 | except InvalidNamespace: | 307 | except InvalidNamespace: |
407 | 316 | raise faults.PermissionDenied( | 308 | raise faults.PermissionDenied( |
408 | @@ -331,56 +323,27 @@ | |||
409 | 331 | raise faults.PermissionDenied(unicode(e)) | 323 | raise faults.PermissionDenied(unicode(e)) |
410 | 332 | 324 | ||
411 | 333 | try: | 325 | try: |
459 | 334 | repository = namespace.createRepository( | 326 | try: |
460 | 335 | GitRepositoryType.HOSTED, requester, repository_name) | 327 | namespace.createRepository( |
461 | 336 | except LaunchpadValidationError as e: | 328 | GitRepositoryType.HOSTED, requester, repository_name, |
462 | 337 | # Despite the fault name, this just passes through the exception | 329 | target_default=target_default, owner_default=owner_default, |
463 | 338 | # text so there's no need for a new Git-specific fault. | 330 | with_hosting=True) |
464 | 339 | raise faults.InvalidBranchName(e) | 331 | except LaunchpadValidationError as e: |
465 | 340 | except GitRepositoryExists as e: | 332 | # Despite the fault name, this just passes through the |
466 | 341 | # We should never get here, as we just tried to translate the | 333 | # exception text so there's no need for a new Git-specific |
467 | 342 | # path and found nothing (not even an inaccessible private | 334 | # fault. |
468 | 343 | # repository). Log an OOPS for investigation. | 335 | raise faults.InvalidBranchName(e) |
469 | 344 | self._reportError(path, e) | 336 | except GitRepositoryExists as e: |
470 | 345 | except GitRepositoryCreationException as e: | 337 | # We should never get here, as we just tried to translate |
471 | 346 | raise faults.PermissionDenied(unicode(e)) | 338 | # the path and found nothing (not even an inaccessible |
472 | 347 | 339 | # private repository). Log an OOPS for investigation. | |
473 | 348 | try: | 340 | self._reportError(path, e) |
474 | 349 | if default_func: | 341 | except (GitRepositoryCreationException, Unauthorized) as e: |
475 | 350 | try: | 342 | raise faults.PermissionDenied(unicode(e)) |
429 | 351 | default_func(repository) | ||
430 | 352 | except Unauthorized: | ||
431 | 353 | raise faults.PermissionDenied( | ||
432 | 354 | "You cannot set the default Git repository for '%s'." % | ||
433 | 355 | path) | ||
434 | 356 | |||
435 | 357 | # Flush to make sure that repository.id is populated. | ||
436 | 358 | Store.of(repository).flush() | ||
437 | 359 | assert repository.id is not None | ||
438 | 360 | |||
439 | 361 | # If repository has target_default, clone from default. | ||
440 | 362 | target_path = None | ||
441 | 363 | try: | ||
442 | 364 | default = self.repository_set.getDefaultRepository( | ||
443 | 365 | repository.target) | ||
444 | 366 | if default is not None and default.visibleByUser(requester): | ||
445 | 367 | target_path = default.getInternalPath() | ||
446 | 368 | else: | ||
447 | 369 | default = self.repository_set.getDefaultRepositoryForOwner( | ||
448 | 370 | repository.owner, repository.target) | ||
449 | 371 | if (default is not None and | ||
450 | 372 | default.visibleByUser(requester)): | ||
451 | 373 | target_path = default.getInternalPath() | ||
452 | 374 | except GitTargetError: | ||
453 | 375 | pass # Ignore Personal repositories. | ||
454 | 376 | |||
455 | 377 | hosting_path = repository.getInternalPath() | ||
456 | 378 | try: | ||
457 | 379 | getUtility(IGitHostingClient).create( | ||
458 | 380 | hosting_path, clone_from=target_path) | ||
476 | 381 | except GitRepositoryCreationFault as e: | 343 | except GitRepositoryCreationFault as e: |
477 | 382 | # The hosting service failed. Log an OOPS for investigation. | 344 | # The hosting service failed. Log an OOPS for investigation. |
479 | 383 | self._reportError(path, e, hosting_path=hosting_path) | 345 | self._reportError(path, e, hosting_path=e.path) |
480 | 346 | raise | ||
481 | 384 | except Exception: | 347 | except Exception: |
482 | 385 | # We don't want to keep the repository we created. | 348 | # We don't want to keep the repository we created. |
483 | 386 | transaction.abort() | 349 | transaction.abort() |
484 | 387 | 350 | ||
485 | === modified file 'lib/lp/code/xmlrpc/tests/test_git.py' | |||
486 | --- lib/lp/code/xmlrpc/tests/test_git.py 2019-09-05 11:29:00 +0000 | |||
487 | +++ lib/lp/code/xmlrpc/tests/test_git.py 2019-09-26 15:27:30 +0000 | |||
488 | @@ -908,7 +908,7 @@ | |||
489 | 908 | # If the hosting service is down, trying to create a repository | 908 | # If the hosting service is down, trying to create a repository |
490 | 909 | # fails and doesn't leave junk around in the Launchpad database. | 909 | # fails and doesn't leave junk around in the Launchpad database. |
491 | 910 | self.hosting_fixture.create.failure = GitRepositoryCreationFault( | 910 | self.hosting_fixture.create.failure = GitRepositoryCreationFault( |
493 | 911 | "nothing here") | 911 | "nothing here", path="123") |
494 | 912 | requester = self.factory.makePerson() | 912 | requester = self.factory.makePerson() |
495 | 913 | initial_count = getUtility(IAllGitRepositories).count() | 913 | initial_count = getUtility(IAllGitRepositories).count() |
496 | 914 | oops_id = self.assertOopsOccurred( | 914 | oops_id = self.assertOopsOccurred( |
Superseded by https:/ /code.launchpad .net/~cjwatson/ launchpad/ +git/launchpad/ +merge/ 373763.