Merge ~cjwatson/launchpad:charm-recipe-request-builds into launchpad:master
- Git
- lp:~cjwatson/launchpad
- charm-recipe-request-builds
- Merge into master
Proposed by
Colin Watson
Status: | Merged |
---|---|
Approved by: | Colin Watson |
Approved revision: | 7cb2458a8c0c5c02504902d5b1b0602d35215711 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~cjwatson/launchpad:charm-recipe-request-builds |
Merge into: | launchpad:master |
Prerequisite: | ~cjwatson/launchpad:charm-recipe-build |
Diff against target: |
483 lines (+318/-1) 5 files modified
lib/lp/charms/interfaces/charmrecipe.py (+35/-0) lib/lp/charms/model/charmrecipe.py (+101/-0) lib/lp/charms/tests/test_charmrecipe.py (+148/-0) lib/lp/security.py (+30/-0) lib/lp/testing/factory.py (+4/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Cristian Gonzalez (community) | Approve | ||
Review via email: mp+403563@code.launchpad.net |
Commit message
Implement CharmRecipe.
Description of the change
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py |
2 | index 8b38153..d625615 100644 |
3 | --- a/lib/lp/charms/interfaces/charmrecipe.py |
4 | +++ b/lib/lp/charms/interfaces/charmrecipe.py |
5 | @@ -12,6 +12,8 @@ __all__ = [ |
6 | "CHARM_RECIPE_ALLOW_CREATE", |
7 | "CHARM_RECIPE_BUILD_DISTRIBUTION", |
8 | "CHARM_RECIPE_PRIVATE_FEATURE_FLAG", |
9 | + "CharmRecipeBuildAlreadyPending", |
10 | + "CharmRecipeBuildDisallowedArchitecture", |
11 | "CharmRecipeBuildRequestStatus", |
12 | "CharmRecipeFeatureDisabled", |
13 | "CharmRecipeNotOwner", |
14 | @@ -139,6 +141,25 @@ class BadCharmRecipeSearchContext(Exception): |
15 | """The context is not valid for a charm recipe search.""" |
16 | |
17 | |
18 | +@error_status(http_client.BAD_REQUEST) |
19 | +class CharmRecipeBuildAlreadyPending(Exception): |
20 | + """A build was requested when an identical build was already pending.""" |
21 | + |
22 | + def __init__(self): |
23 | + super(CharmRecipeBuildAlreadyPending, self).__init__( |
24 | + "An identical build of this charm recipe is already pending.") |
25 | + |
26 | + |
27 | +@error_status(http_client.BAD_REQUEST) |
28 | +class CharmRecipeBuildDisallowedArchitecture(Exception): |
29 | + """A build was requested for a disallowed architecture.""" |
30 | + |
31 | + def __init__(self, das): |
32 | + super(CharmRecipeBuildDisallowedArchitecture, self).__init__( |
33 | + "This charm recipe is not allowed to build for %s/%s." % |
34 | + (das.distroseries.name, das.architecturetag)) |
35 | + |
36 | + |
37 | class CharmRecipeBuildRequestStatus(EnumeratedType): |
38 | """The status of a request to build a charm recipe.""" |
39 | |
40 | @@ -233,6 +254,20 @@ class ICharmRecipeView(Interface): |
41 | def visibleByUser(user): |
42 | """Can the specified user see this charm recipe?""" |
43 | |
44 | + def requestBuild(build_request, distro_arch_series, channels=None): |
45 | + """Request a single build of this charm recipe. |
46 | + |
47 | + This method is for internal use; external callers should use |
48 | + `requestBuilds` instead. |
49 | + |
50 | + :param build_request: The `ICharmRecipeBuildRequest` job being |
51 | + processed. |
52 | + :param distro_arch_series: The architecture to build for. |
53 | + :param channels: A dictionary mapping snap names to channels to use |
54 | + for this build. |
55 | + :return: `ICharmRecipeBuild`. |
56 | + """ |
57 | + |
58 | def requestBuilds(requester, channels=None, architectures=None): |
59 | """Request that the charm recipe be built. |
60 | |
61 | diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py |
62 | index 7bb0d62..cbb078d 100644 |
63 | --- a/lib/lp/charms/model/charmrecipe.py |
64 | +++ b/lib/lp/charms/model/charmrecipe.py |
65 | @@ -10,16 +10,23 @@ __all__ = [ |
66 | "CharmRecipe", |
67 | ] |
68 | |
69 | +from operator import itemgetter |
70 | + |
71 | +from lazr.lifecycle.event import ObjectCreatedEvent |
72 | import pytz |
73 | from storm.databases.postgres import JSON |
74 | from storm.locals import ( |
75 | Bool, |
76 | DateTime, |
77 | Int, |
78 | + Join, |
79 | + Or, |
80 | Reference, |
81 | + Store, |
82 | Unicode, |
83 | ) |
84 | from zope.component import getUtility |
85 | +from zope.event import notify |
86 | from zope.interface import implementer |
87 | from zope.security.proxy import removeSecurityProxy |
88 | |
89 | @@ -29,10 +36,13 @@ from lp.app.enums import ( |
90 | PUBLIC_INFORMATION_TYPES, |
91 | ) |
92 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
93 | +from lp.buildmaster.enums import BuildStatus |
94 | from lp.charms.interfaces.charmrecipe import ( |
95 | CHARM_RECIPE_ALLOW_CREATE, |
96 | CHARM_RECIPE_BUILD_DISTRIBUTION, |
97 | CHARM_RECIPE_PRIVATE_FEATURE_FLAG, |
98 | + CharmRecipeBuildAlreadyPending, |
99 | + CharmRecipeBuildDisallowedArchitecture, |
100 | CharmRecipeBuildRequestStatus, |
101 | CharmRecipeFeatureDisabled, |
102 | CharmRecipeNotOwner, |
103 | @@ -44,9 +54,11 @@ from lp.charms.interfaces.charmrecipe import ( |
104 | ICharmRecipeSet, |
105 | NoSourceForCharmRecipe, |
106 | ) |
107 | +from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet |
108 | from lp.charms.interfaces.charmrecipejob import ( |
109 | ICharmRecipeRequestBuildsJobSource, |
110 | ) |
111 | +from lp.charms.model.charmrecipebuild import CharmRecipeBuild |
112 | from lp.code.model.gitcollection import GenericGitCollection |
113 | from lp.code.model.gitrepository import GitRepository |
114 | from lp.registry.errors import PrivatePersonLinkageError |
115 | @@ -55,11 +67,15 @@ from lp.registry.interfaces.person import ( |
116 | IPersonSet, |
117 | validate_public_person, |
118 | ) |
119 | +from lp.registry.model.distribution import Distribution |
120 | +from lp.registry.model.distroseries import DistroSeries |
121 | +from lp.registry.model.series import ACTIVE_STATUSES |
122 | from lp.services.database.bulk import load_related |
123 | from lp.services.database.constants import ( |
124 | DEFAULT, |
125 | UTC_NOW, |
126 | ) |
127 | +from lp.services.database.decoratedresultset import DecoratedResultSet |
128 | from lp.services.database.enumcol import DBEnum |
129 | from lp.services.database.interfaces import ( |
130 | IMasterStore, |
131 | @@ -68,10 +84,15 @@ from lp.services.database.interfaces import ( |
132 | from lp.services.database.stormbase import StormBase |
133 | from lp.services.features import getFeatureFlag |
134 | from lp.services.job.interfaces.job import JobStatus |
135 | +from lp.services.librarian.model import LibraryFileAlias |
136 | from lp.services.propertycache import ( |
137 | cachedproperty, |
138 | get_property_cache, |
139 | ) |
140 | +from lp.soyuz.model.distroarchseries import ( |
141 | + DistroArchSeries, |
142 | + PocketChroot, |
143 | + ) |
144 | |
145 | |
146 | def charm_recipe_modified(recipe, event): |
147 | @@ -342,6 +363,48 @@ class CharmRecipe(StormBase): |
148 | # more privacy infrastructure. |
149 | return False |
150 | |
151 | + def _isBuildableArchitectureAllowed(self, das): |
152 | + """Check whether we may build for a buildable `DistroArchSeries`. |
153 | + |
154 | + The caller is assumed to have already checked that a suitable chroot |
155 | + is available (either directly or via |
156 | + `DistroSeries.buildable_architectures`). |
157 | + """ |
158 | + return ( |
159 | + das.enabled |
160 | + and ( |
161 | + das.processor.supports_virtualized |
162 | + or not self.require_virtualized)) |
163 | + |
164 | + def _isArchitectureAllowed(self, das): |
165 | + """Check whether we may build for a `DistroArchSeries`.""" |
166 | + return ( |
167 | + das.getChroot() is not None |
168 | + and self._isBuildableArchitectureAllowed(das)) |
169 | + |
170 | + def getAllowedArchitectures(self): |
171 | + """See `IOCIRecipe`.""" |
172 | + store = Store.of(self) |
173 | + origin = [ |
174 | + DistroArchSeries, |
175 | + Join(DistroSeries, |
176 | + DistroArchSeries.distroseries == DistroSeries.id), |
177 | + Join(Distribution, DistroSeries.distribution == Distribution.id), |
178 | + Join(PocketChroot, |
179 | + PocketChroot.distroarchseries == DistroArchSeries.id), |
180 | + Join(LibraryFileAlias, |
181 | + PocketChroot.chroot == LibraryFileAlias.id), |
182 | + ] |
183 | + # Preload DistroSeries and Distribution, since we'll need those in |
184 | + # determine_architectures_to_build. |
185 | + results = store.using(*origin).find( |
186 | + (DistroArchSeries, DistroSeries, Distribution), |
187 | + DistroSeries.status.is_in(ACTIVE_STATUSES)) |
188 | + all_buildable_dases = DecoratedResultSet(results, itemgetter(0)) |
189 | + return [ |
190 | + das for das in all_buildable_dases |
191 | + if self._isBuildableArchitectureAllowed(das)] |
192 | + |
193 | def _checkRequestBuild(self, requester): |
194 | """May `requester` request builds of this charm recipe?""" |
195 | if not requester.inTeam(self.owner): |
196 | @@ -349,6 +412,44 @@ class CharmRecipe(StormBase): |
197 | "%s cannot create charm recipe builds owned by %s." % |
198 | (requester.display_name, self.owner.display_name)) |
199 | |
200 | + def requestBuild(self, build_request, distro_arch_series, channels=None): |
201 | + """Request a single build of this charm recipe. |
202 | + |
203 | + This method is for internal use; external callers should use |
204 | + `requestBuilds` instead. |
205 | + |
206 | + :param build_request: The `ICharmRecipeBuildRequest` job being |
207 | + processed. |
208 | + :param distro_arch_series: The architecture to build for. |
209 | + :param channels: A dictionary mapping snap names to channels to use |
210 | + for this build. |
211 | + :return: `ICharmRecipeBuild`. |
212 | + """ |
213 | + self._checkRequestBuild(build_request.requester) |
214 | + if not self._isArchitectureAllowed(distro_arch_series): |
215 | + raise CharmRecipeBuildDisallowedArchitecture(distro_arch_series) |
216 | + |
217 | + if not channels: |
218 | + channels_clause = Or( |
219 | + CharmRecipeBuild.channels == None, |
220 | + CharmRecipeBuild.channels == {}) |
221 | + else: |
222 | + channels_clause = CharmRecipeBuild.channels == channels |
223 | + pending = IStore(self).find( |
224 | + CharmRecipeBuild, |
225 | + CharmRecipeBuild.recipe == self, |
226 | + CharmRecipeBuild.processor == distro_arch_series.processor, |
227 | + channels_clause, |
228 | + CharmRecipeBuild.status == BuildStatus.NEEDSBUILD) |
229 | + if pending.any() is not None: |
230 | + raise CharmRecipeBuildAlreadyPending |
231 | + |
232 | + build = getUtility(ICharmRecipeBuildSet).new( |
233 | + build_request, self, distro_arch_series, channels=channels) |
234 | + build.queueBuild() |
235 | + notify(ObjectCreatedEvent(build, user=build_request.requester)) |
236 | + return build |
237 | + |
238 | def requestBuilds(self, requester, channels=None, architectures=None): |
239 | """See `ICharmRecipe`.""" |
240 | self._checkRequestBuild(requester) |
241 | diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py |
242 | index bf5d0d0..cd53f3e 100644 |
243 | --- a/lib/lp/charms/tests/test_charmrecipe.py |
244 | +++ b/lib/lp/charms/tests/test_charmrecipe.py |
245 | @@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals |
246 | |
247 | __metaclass__ = type |
248 | |
249 | +from storm.locals import Store |
250 | from testtools.matchers import ( |
251 | Equals, |
252 | Is, |
253 | @@ -18,9 +19,21 @@ from zope.component import getUtility |
254 | from zope.security.proxy import removeSecurityProxy |
255 | |
256 | from lp.app.enums import InformationType |
257 | +from lp.buildmaster.enums import ( |
258 | + BuildQueueStatus, |
259 | + BuildStatus, |
260 | + ) |
261 | +from lp.buildmaster.interfaces.buildqueue import IBuildQueue |
262 | +from lp.buildmaster.interfaces.processor import ( |
263 | + IProcessorSet, |
264 | + ProcessorNotFound, |
265 | + ) |
266 | +from lp.buildmaster.model.buildqueue import BuildQueue |
267 | from lp.charms.interfaces.charmrecipe import ( |
268 | CHARM_RECIPE_ALLOW_CREATE, |
269 | CHARM_RECIPE_BUILD_DISTRIBUTION, |
270 | + CharmRecipeBuildAlreadyPending, |
271 | + CharmRecipeBuildDisallowedArchitecture, |
272 | CharmRecipeBuildRequestStatus, |
273 | CharmRecipeFeatureDisabled, |
274 | CharmRecipePrivateFeatureDisabled, |
275 | @@ -28,6 +41,7 @@ from lp.charms.interfaces.charmrecipe import ( |
276 | ICharmRecipeSet, |
277 | NoSourceForCharmRecipe, |
278 | ) |
279 | +from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild |
280 | from lp.charms.interfaces.charmrecipejob import ( |
281 | ICharmRecipeRequestBuildsJobSource, |
282 | ) |
283 | @@ -170,6 +184,140 @@ class TestCharmRecipe(TestCaseWithFactory): |
284 | current_series, |
285 | removeSecurityProxy(recipe)._default_distro_series) |
286 | |
287 | + def makeBuildableDistroArchSeries(self, architecturetag=None, |
288 | + processor=None, |
289 | + supports_virtualized=True, |
290 | + supports_nonvirtualized=True, **kwargs): |
291 | + if architecturetag is None: |
292 | + architecturetag = self.factory.getUniqueUnicode("arch") |
293 | + if processor is None: |
294 | + try: |
295 | + processor = getUtility(IProcessorSet).getByName( |
296 | + architecturetag) |
297 | + except ProcessorNotFound: |
298 | + processor = self.factory.makeProcessor( |
299 | + name=architecturetag, |
300 | + supports_virtualized=supports_virtualized, |
301 | + supports_nonvirtualized=supports_nonvirtualized) |
302 | + das = self.factory.makeDistroArchSeries( |
303 | + architecturetag=architecturetag, processor=processor, **kwargs) |
304 | + fake_chroot = self.factory.makeLibraryFileAlias( |
305 | + filename="fake_chroot.tar.gz", db_only=True) |
306 | + das.addOrUpdateChroot(fake_chroot) |
307 | + return das |
308 | + |
309 | + def test_requestBuild(self): |
310 | + # requestBuild creates a new CharmRecipeBuild. |
311 | + recipe = self.factory.makeCharmRecipe() |
312 | + das = self.makeBuildableDistroArchSeries() |
313 | + build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe) |
314 | + build = recipe.requestBuild(build_request, das) |
315 | + self.assertTrue(ICharmRecipeBuild.providedBy(build)) |
316 | + self.assertThat(build, MatchesStructure( |
317 | + requester=Equals(recipe.owner.teamowner), |
318 | + distro_arch_series=Equals(das), |
319 | + channels=Is(None), |
320 | + status=Equals(BuildStatus.NEEDSBUILD), |
321 | + )) |
322 | + store = Store.of(build) |
323 | + store.flush() |
324 | + build_queue = store.find( |
325 | + BuildQueue, |
326 | + BuildQueue._build_farm_job_id == |
327 | + removeSecurityProxy(build).build_farm_job_id).one() |
328 | + self.assertProvides(build_queue, IBuildQueue) |
329 | + self.assertEqual(recipe.require_virtualized, build_queue.virtualized) |
330 | + self.assertEqual(BuildQueueStatus.WAITING, build_queue.status) |
331 | + |
332 | + def test_requestBuild_score(self): |
333 | + # Build requests have a relatively low queue score (2510). |
334 | + recipe = self.factory.makeCharmRecipe() |
335 | + das = self.makeBuildableDistroArchSeries() |
336 | + build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe) |
337 | + build = recipe.requestBuild(build_request, das) |
338 | + queue_record = build.buildqueue_record |
339 | + queue_record.score() |
340 | + self.assertEqual(2510, queue_record.lastscore) |
341 | + |
342 | + def test_requestBuild_channels(self): |
343 | + # requestBuild can select non-default channels. |
344 | + recipe = self.factory.makeCharmRecipe() |
345 | + das = self.makeBuildableDistroArchSeries() |
346 | + build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe) |
347 | + build = recipe.requestBuild( |
348 | + build_request, das, channels={"charmcraft": "edge"}) |
349 | + self.assertEqual({"charmcraft": "edge"}, build.channels) |
350 | + |
351 | + def test_requestBuild_rejects_repeats(self): |
352 | + # requestBuild refuses if there is already a pending build. |
353 | + recipe = self.factory.makeCharmRecipe() |
354 | + distro_series = self.factory.makeDistroSeries() |
355 | + arches = [ |
356 | + self.makeBuildableDistroArchSeries(distroseries=distro_series) |
357 | + for _ in range(2)] |
358 | + build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe) |
359 | + old_build = recipe.requestBuild(build_request, arches[0]) |
360 | + self.assertRaises( |
361 | + CharmRecipeBuildAlreadyPending, recipe.requestBuild, |
362 | + build_request, arches[0]) |
363 | + # We can build for a different distroarchseries. |
364 | + recipe.requestBuild(build_request, arches[1]) |
365 | + # channels=None and channels={} are treated as equivalent, but |
366 | + # anything else allows a new build. |
367 | + self.assertRaises( |
368 | + CharmRecipeBuildAlreadyPending, recipe.requestBuild, |
369 | + build_request, arches[0], channels={}) |
370 | + recipe.requestBuild( |
371 | + build_request, arches[0], channels={"core": "edge"}) |
372 | + self.assertRaises( |
373 | + CharmRecipeBuildAlreadyPending, recipe.requestBuild, |
374 | + build_request, arches[0], channels={"core": "edge"}) |
375 | + # Changing the status of the old build allows a new build. |
376 | + old_build.updateStatus(BuildStatus.BUILDING) |
377 | + old_build.updateStatus(BuildStatus.FULLYBUILT) |
378 | + recipe.requestBuild(build_request, arches[0]) |
379 | + |
380 | + def test_requestBuild_virtualization(self): |
381 | + # New builds are virtualized if any of the processor or recipe |
382 | + # require it. |
383 | + recipe = self.factory.makeCharmRecipe() |
384 | + distro_series = self.factory.makeDistroSeries() |
385 | + dases = {} |
386 | + for proc_nonvirt in True, False: |
387 | + das = self.makeBuildableDistroArchSeries( |
388 | + distroseries=distro_series, supports_virtualized=True, |
389 | + supports_nonvirtualized=proc_nonvirt) |
390 | + dases[proc_nonvirt] = das |
391 | + for proc_nonvirt, recipe_virt, build_virt in ( |
392 | + (True, False, False), |
393 | + (True, True, True), |
394 | + (False, False, True), |
395 | + (False, True, True), |
396 | + ): |
397 | + das = dases[proc_nonvirt] |
398 | + recipe = self.factory.makeCharmRecipe( |
399 | + require_virtualized=recipe_virt) |
400 | + build_request = self.factory.makeCharmRecipeBuildRequest( |
401 | + recipe=recipe) |
402 | + build = recipe.requestBuild(build_request, das) |
403 | + self.assertEqual(build_virt, build.virtualized) |
404 | + |
405 | + def test_requestBuild_nonvirtualized(self): |
406 | + # A non-virtualized processor can build a charm recipe iff the |
407 | + # recipe has require_virtualized set to False. |
408 | + recipe = self.factory.makeCharmRecipe() |
409 | + distro_series = self.factory.makeDistroSeries() |
410 | + das = self.makeBuildableDistroArchSeries( |
411 | + distroseries=distro_series, supports_virtualized=False, |
412 | + supports_nonvirtualized=True) |
413 | + build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe) |
414 | + self.assertRaises( |
415 | + CharmRecipeBuildDisallowedArchitecture, recipe.requestBuild, |
416 | + build_request, das) |
417 | + with admin_logged_in(): |
418 | + recipe.require_virtualized = False |
419 | + recipe.requestBuild(build_request, das) |
420 | + |
421 | def test_requestBuilds(self): |
422 | # requestBuilds schedules a job and returns a corresponding |
423 | # CharmRecipeBuildRequest. |
424 | diff --git a/lib/lp/security.py b/lib/lp/security.py |
425 | index e11855f..6108048 100644 |
426 | --- a/lib/lp/security.py |
427 | +++ b/lib/lp/security.py |
428 | @@ -69,6 +69,7 @@ from lp.charms.interfaces.charmrecipe import ( |
429 | ICharmRecipe, |
430 | ICharmRecipeBuildRequest, |
431 | ) |
432 | +from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild |
433 | from lp.code.interfaces.branch import ( |
434 | IBranch, |
435 | user_has_special_branch_access, |
436 | @@ -3668,3 +3669,32 @@ class ViewCharmRecipeBuildRequest(DelegatedAuthorization): |
437 | def __init__(self, obj): |
438 | super(ViewCharmRecipeBuildRequest, self).__init__( |
439 | obj, obj.recipe, 'launchpad.View') |
440 | + |
441 | + |
442 | +class ViewCharmRecipeBuild(DelegatedAuthorization): |
443 | + permission = 'launchpad.View' |
444 | + usedfor = ICharmRecipeBuild |
445 | + |
446 | + def iter_objects(self): |
447 | + yield self.obj.recipe |
448 | + |
449 | + |
450 | +class EditCharmRecipeBuild(AdminByBuilddAdmin): |
451 | + permission = 'launchpad.Edit' |
452 | + usedfor = ICharmRecipeBuild |
453 | + |
454 | + def checkAuthenticated(self, user): |
455 | + """Check edit access for snap package builds. |
456 | + |
457 | + Allow admins, buildd admins, and the owner of the charm recipe. |
458 | + (Note that the requester of the build is required to be in the team |
459 | + that owns the charm recipe.) |
460 | + """ |
461 | + auth_recipe = EditCharmRecipe(self.obj.recipe) |
462 | + if auth_recipe.checkAuthenticated(user): |
463 | + return True |
464 | + return super(EditCharmRecipeBuild, self).checkAuthenticated(user) |
465 | + |
466 | + |
467 | +class AdminCharmRecipeBuild(AdminByBuilddAdmin): |
468 | + usedfor = ICharmRecipeBuild |
469 | diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py |
470 | index e68c408..4c125a5 100644 |
471 | --- a/lib/lp/testing/factory.py |
472 | +++ b/lib/lp/testing/factory.py |
473 | @@ -5158,7 +5158,10 @@ class BareLaunchpadObjectFactory(ObjectFactory): |
474 | if recipe is None: |
475 | recipe = self.makeCharmRecipe() |
476 | if requester is None: |
477 | - requester = recipe.owner.teamowner |
478 | + if recipe.owner.is_team: |
479 | + requester = recipe.owner.teamowner |
480 | + else: |
481 | + requester = recipe.owner |
482 | return recipe.requestBuilds( |
483 | requester, channels=channels, architectures=architectures) |
484 |
Looks good!