Merge ~twom/launchpad:oci-add-registry-status-to-recipe-page into launchpad:master

Proposed by Tom Wardill
Status: Merged
Approved by: Tom Wardill
Approved revision: 6e92e27b324b7ef525f5c94ce934b287152fbec7
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~twom/launchpad:oci-add-registry-status-to-recipe-page
Merge into: launchpad:master
Diff against target: 745 lines (+425/-76)
7 files modified
lib/lp/oci/browser/ocirecipe.py (+52/-1)
lib/lp/oci/browser/tests/test_ocirecipe.py (+120/-38)
lib/lp/oci/interfaces/ocirecipe.py (+31/-0)
lib/lp/oci/interfaces/ocirecipejob.py (+3/-0)
lib/lp/oci/model/ocirecipe.py (+61/-0)
lib/lp/oci/model/ocirecipejob.py (+65/-3)
lib/lp/oci/templates/ocirecipe-index.pt (+93/-34)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+401144@code.launchpad.net

Commit message

Add registry upload status to OCIRecipe:+index

Description of the change

The status of the registry upload for an OCIRecipeBuild was hidden on the build page. The upload could fail, while the build succeeds, leading the +index to confusingly say that everything had succeeded.

Rebuild +index to have a table based on OCIRequestBuildJob, listing the individual builds for a request and their upload status.

Some recipes are old enough to have builds that do not use the async requestBuilds, so munge those into something that looks like a request with a single build purely for display purposes.

This is based on the BuildSetStatus enum, with some later enhancements for the data to be aware of a set of Registry Uploads, as well as Builds.

To post a comment you must log in.
Revision history for this message
Tom Wardill (twom) wrote :
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
2index 21dad88..f55bf1d 100644
3--- a/lib/lp/oci/browser/ocirecipe.py
4+++ b/lib/lp/oci/browser/ocirecipe.py
5@@ -43,6 +43,7 @@ from zope.schema import (
6 ValidationError,
7 )
8 from zope.security.interfaces import Unauthorized
9+from zope.security.proxy import removeSecurityProxy
10
11 from lp.app.browser.launchpadform import (
12 action,
13@@ -75,7 +76,11 @@ from lp.oci.interfaces.ocirecipe import (
14 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
15 OCIRecipeFeatureDisabled,
16 )
17-from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
18+from lp.oci.interfaces.ocirecipebuild import (
19+ IOCIRecipeBuildSet,
20+ OCIRecipeBuildRegistryUploadStatus,
21+ )
22+from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
23 from lp.oci.interfaces.ociregistrycredentials import (
24 IOCIRegistryCredentialsSet,
25 OCIRegistryCredentialsAlreadyExist,
26@@ -101,6 +106,7 @@ from lp.services.webapp.breadcrumb import NameBreadcrumb
27 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
28 from lp.soyuz.browser.archive import EnableProcessorsMixin
29 from lp.soyuz.browser.build import get_build_by_id_str
30+from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
31
32
33 class OCIRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
34@@ -308,6 +314,51 @@ class OCIRecipeView(LaunchpadView):
35 distro = oci_project.distribution
36 return bool(distro and distro.oci_registry_credentials)
37
38+ def getImageForStatus(self, status):
39+ image_map = {
40+ BuildSetStatus.NEEDSBUILD: '/@@/build-needed',
41+ BuildSetStatus.FULLYBUILT_PENDING: '/@@/build-success-publishing',
42+ BuildSetStatus.FAILEDTOBUILD: '/@@/no',
43+ BuildSetStatus.BUILDING: '/@@/processing',
44+ }
45+ return image_map.get(
46+ status, '/@@/yes')
47+
48+ def _convertBuildJobToStatus(self, build_job):
49+ recipe_set = getUtility(IOCIRecipeSet)
50+ unscheduled_upload = OCIRecipeBuildRegistryUploadStatus.UNSCHEDULED
51+ upload_status = build_job.registry_upload_status
52+ # This is just a dict, but zope wraps it as RecipeSet is secured
53+ status = removeSecurityProxy(
54+ recipe_set.getStatusSummaryForBuilds([build_job]))
55+ # Add the registry job status
56+ status["upload_scheduled"] = upload_status != unscheduled_upload
57+ status["upload"] = upload_status
58+ status["date"] = build_job.date
59+ status["date_estimated"] = build_job.estimate
60+ return {
61+ "builds": [build_job],
62+ "job_id": "build{}".format(build_job.id),
63+ "date_created": build_job.date_created,
64+ "date_finished": build_job.date_finished,
65+ "build_status": status
66+ }
67+
68+ def build_requests(self):
69+ req_util = getUtility(IOCIRecipeRequestBuildsJobSource)
70+ build_requests = list(req_util.findByOCIRecipe(self.context)[:10])
71+
72+ # It's possible that some recipes have builds that are older
73+ # than the introduction of the async requestBuilds.
74+ # In that case, convert the single build to a fake 'request build job'
75+ # that has a single attached build.
76+ if len(build_requests) < 10:
77+ recipe = self.context
78+ no_request_builds = recipe.completed_builds_without_build_request
79+ for build in no_request_builds[:10 - len(build_requests)]:
80+ build_requests.append(self._convertBuildJobToStatus(build))
81+ return build_requests[:10]
82+
83
84 def builds_for_recipe(recipe):
85 """A list of interesting builds.
86diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
87index 7049d9c..54d4b10 100644
88--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
89+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
90@@ -1255,25 +1255,53 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
91 "OCI recipe breadcrumb", "li",
92 text=re.compile(r"\srecipe-name\s")))))
93
94+ def makeRecipe(self, processor_names, **kwargs):
95+ recipe = self.factory.makeOCIRecipe(**kwargs)
96+ processors_list = []
97+ distroseries = self.factory.makeDistroSeries(
98+ distribution=recipe.oci_project.distribution)
99+ for proc_name in processor_names:
100+ proc = getUtility(IProcessorSet).getByName(proc_name)
101+ distro = self.factory.makeDistroArchSeries(
102+ distroseries=distroseries, architecturetag=proc_name,
103+ processor=proc)
104+ distro.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
105+ processors_list.append(proc)
106+ recipe.setProcessors(processors_list)
107+ return recipe
108+
109 def test_index(self):
110 oci_project = self.factory.makeOCIProject(
111 pillar=self.distroseries.distribution)
112- oci_project_name = oci_project.name
113 oci_project_display = oci_project.display_name
114 [ref] = self.factory.makeGitRefs(
115 owner=self.person, target=self.person, name="recipe-repository",
116 paths=["refs/heads/master"])
117- recipe = self.makeOCIRecipe(
118- oci_project=oci_project, git_ref=ref, build_file="Dockerfile")
119+ recipe = self.makeRecipe(
120+ processor_names=["amd64", "386"],
121+ build_file="Dockerfile", git_ref=ref,
122+ oci_project=oci_project, registrant=self.person, owner=self.person)
123+ build_request = recipe.requestBuilds(self.person)
124+ builds = recipe.requestBuildsFromJob(self.person, build_request)
125+ job = removeSecurityProxy(build_request).job
126+ removeSecurityProxy(job).builds = builds
127+
128+ for build in builds:
129+ removeSecurityProxy(build).updateStatus(
130+ BuildStatus.BUILDING, builder=None,
131+ date_started=build.date_created)
132+ removeSecurityProxy(build).updateStatus(
133+ BuildStatus.FULLYBUILT, builder=None,
134+ date_finished=build.date_started + timedelta(minutes=30))
135+
136+ # We also need to account for builds that don't have a build_request
137 build = self.makeBuild(
138 recipe=recipe, status=BuildStatus.FULLYBUILT,
139 duration=timedelta(minutes=30))
140
141- browser = self.getViewBrowser(build.recipe)
142+ browser = self.getViewBrowser(build_request.recipe)
143 login_person(self.person)
144 self.assertTextMatchesExpressionIgnoreWhitespace("""\
145- %s OCI project
146- recipe-name
147 .*
148 OCI recipe information
149 Owner: Test Person
150@@ -1285,9 +1313,33 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
151 Official recipe:
152 No
153 Latest builds
154- Status When complete Architecture
155- Successfully built 30 minutes ago 386
156- """ % (oci_project_name, oci_project_display, recipe.build_path),
157+ Build status
158+ Upload status
159+ When requested
160+ When complete
161+ All builds were built successfully.
162+ No registry upload requested.
163+ a moment ago
164+ in 29 minutes
165+ amd64
166+ Successfully built
167+ 386
168+ Successfully built
169+ amd64
170+ in 29 minutes
171+ 386
172+ in 29 minutes
173+ All builds were built successfully.
174+ No registry upload requested.
175+ 1 hour ago
176+ 30 minutes ago
177+ 386
178+ Successfully built
179+ 386
180+ 30 minutes ago
181+ Recipe push rules
182+ This OCI recipe has no push rules defined yet.
183+ """ % (oci_project_display, recipe.build_path),
184 extract_text(find_main_content(browser.contents)))
185
186 # Check portlet on side menu.
187@@ -1346,8 +1398,18 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
188 Official recipe:
189 No
190 Latest builds
191- Status When complete Architecture
192- Successfully built 30 minutes ago 386
193+ Build status
194+ Upload status
195+ When requested
196+ When complete
197+ All builds were built successfully.
198+ No registry upload requested.
199+ 1 hour ago
200+ 30 minutes ago
201+ 386
202+ Successfully built
203+ 386
204+ 30 minutes ago
205 """ % (oci_project_name, oci_project_display, build_path),
206 self.getMainText(build.recipe))
207
208@@ -1389,8 +1451,18 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
209 Official recipe:
210 No
211 Latest builds
212- Status When complete Architecture
213- Successfully built 30 minutes ago 386
214+ Build status
215+ Upload status
216+ When requested
217+ When complete
218+ All builds were built successfully.
219+ No registry upload requested.
220+ 1 hour ago
221+ 30 minutes ago
222+ 386
223+ Successfully built
224+ 386
225+ 30 minutes ago
226 """ % (oci_project_name, oci_project_display, build_path),
227 main_text)
228
229@@ -1401,8 +1473,20 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
230 build.setLog(self.factory.makeLibraryFileAlias())
231 self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
232 Latest builds
233- Status When complete Architecture
234- Successfully built 30 minutes ago buildlog \(.*\) 386
235+ Build status
236+ Upload status
237+ When requested
238+ When complete
239+ All builds were built successfully.
240+ No registry upload requested.
241+ 1 hour ago
242+ 30 minutes ago
243+ 386
244+ buildlog
245+ \(.*\)
246+ Successfully built
247+ 386
248+ 30 minutes ago
249 """, self.getMainText(build.recipe))
250
251 def test_index_no_builds(self):
252@@ -1414,13 +1498,29 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
253
254 def test_index_pending_build(self):
255 # A pending build is listed as such.
256- build = self.makeBuild()
257- build.queueBuild()
258+ oci_project = self.factory.makeOCIProject(
259+ pillar=self.distroseries.distribution)
260+ [ref] = self.factory.makeGitRefs(
261+ owner=self.person, target=self.person, name="recipe-repository",
262+ paths=["refs/heads/master"])
263+ recipe = self.makeRecipe(
264+ processor_names=["amd64", "386"],
265+ build_file="Dockerfile", git_ref=ref,
266+ oci_project=oci_project, registrant=self.person, owner=self.person)
267+ build_request = recipe.requestBuilds(self.person)
268+ builds = recipe.requestBuildsFromJob(self.person, build_request)
269+ job = removeSecurityProxy(build_request).job
270+ removeSecurityProxy(job).builds = builds
271 self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
272 Latest builds
273- Status When complete Architecture
274- Needs building in .* \(estimated\) 386
275- """, self.getMainText(build.recipe))
276+ Build status
277+ Upload status
278+ When requested
279+ When complete
280+ There are some builds waiting to be built.
281+ a moment ago
282+ in .* \(estimated\)
283+ """, self.getMainText(recipe))
284
285 def test_index_request_builds_link(self):
286 # Recipe owners get a link to allow requesting builds.
287@@ -1531,24 +1631,6 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
288 with dbuser(config.IOCIRecipeRequestBuildsJobSource.dbuser):
289 JobRunner(jobs).runAll()
290
291- def test_pending_build_requests_not_shown_if_absent(self):
292- self.recipe.requestBuilds(self.recipe.owner)
293- browser = self.getViewBrowser(self.recipe, user=self.person)
294- content = extract_text(find_main_content(browser.contents))
295- self.assertIn(
296- "You have 1 pending build request. "
297- "The builds should start automatically soon.",
298- content.replace("\n", " "))
299-
300- # Run the build request, so we don't have any pending one. The
301- # message should be gone then.
302- self.runRequestBuildJobs()
303- browser = self.getViewBrowser(self.recipe, user=self.person)
304- content = extract_text(find_main_content(browser.contents))
305- self.assertNotIn(
306- "The builds should start automatically soon.",
307- content.replace("\n", " "))
308-
309 def test_request_builds_action(self):
310 # Requesting a build creates pending builds.
311 browser = self.getViewBrowser(
312diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
313index ae682ed..1cd81dd 100644
314--- a/lib/lp/oci/interfaces/ocirecipe.py
315+++ b/lib/lp/oci/interfaces/ocirecipe.py
316@@ -280,6 +280,15 @@ class IOCIRecipeView(Interface):
317 # Really IOCIRecipeBuild, patched in _schema_circular_imports.
318 value_type=Reference(schema=Interface), readonly=True)
319
320+ completed_builds_without_build_request = CollectionField(
321+ title=_("Completed builds of this OCI recipe."),
322+ description=_(
323+ "Completed builds of this OCI recipe, sorted in descending "
324+ "order of finishing that do no have a corresponding "
325+ "build request"),
326+ # Really IOCIRecipeBuild, patched in _schema_circular_imports.
327+ value_type=Reference(schema=Interface), readonly=True)
328+
329 pending_builds = CollectionField(
330 title=_("Pending builds of this OCI recipe."),
331 description=_(
332@@ -586,3 +595,25 @@ class IOCIRecipeSet(Interface):
333 After this, any OCI recipes that previously used this repository
334 will have no source and so cannot dispatch new builds.
335 """
336+
337+ def getStatusSummaryForBuilds(builds):
338+ """Return a summary of the build status for the given builds.
339+
340+ The returned summary includes a status, a description of
341+ that status and the builds related to the status.
342+
343+ :param builds: A list of build records.
344+ :type builds: ``list``
345+ :return: A dict consisting of the build status summary for the
346+ given builds. For example:
347+ {
348+ 'status': BuildSetStatus.FULLYBUILT,
349+ 'builds': [build1, build2]
350+ }
351+ or, an example where there are currently some builds building:
352+ {
353+ 'status': BuildSetStatus.BUILDING,
354+ 'builds':[build3]
355+ }
356+ :rtype: ``dict``.
357+ """
358diff --git a/lib/lp/oci/interfaces/ocirecipejob.py b/lib/lp/oci/interfaces/ocirecipejob.py
359index 2b59faf..5fac142 100644
360--- a/lib/lp/oci/interfaces/ocirecipejob.py
361+++ b/lib/lp/oci/interfaces/ocirecipejob.py
362@@ -90,6 +90,9 @@ class IOCIRecipeRequestBuildsJob(IRunnableJob):
363 BuildRequest.
364 """
365
366+ def build_status():
367+ """Return the status of the builds and the upload to a registry."""
368+
369
370 class IOCIRecipeRequestBuildsJobSource(IJobSource):
371
372diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
373index a56fa7d..3dcb917 100644
374--- a/lib/lp/oci/model/ocirecipe.py
375+++ b/lib/lp/oci/model/ocirecipe.py
376@@ -5,6 +5,9 @@
377
378 from __future__ import absolute_import, print_function, unicode_literals
379
380+from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
381+
382+
383 __metaclass__ = type
384 __all__ = [
385 'get_ocirecipe_privacy_filter',
386@@ -732,6 +735,20 @@ class OCIRecipe(Storm, WebhookTargetMixin):
387 return self._getBuilds(filter_term, order_by)
388
389 @property
390+ def completed_builds_without_build_request(self):
391+ """See `IOCIRecipe`."""
392+ filter_term = (
393+ Not(OCIRecipeBuild.status.is_in(self._pending_states)),
394+ OCIRecipeBuild.build_request_id == None)
395+ order_by = (
396+ NullsLast(Desc(Greatest(
397+ OCIRecipeBuild.date_started,
398+ OCIRecipeBuild.date_finished))),
399+ Desc(OCIRecipeBuild.id))
400+ return self._getBuilds(filter_term, order_by)
401+
402+
403+ @property
404 def pending_builds(self):
405 """See `IOCIRecipe`."""
406 filter_term = (OCIRecipeBuild.status.is_in(self._pending_states))
407@@ -932,6 +949,50 @@ class OCIRecipeSet:
408 if git_ref is not None:
409 recipe.git_ref = git_ref
410
411+ def getStatusSummaryForBuilds(self, builds):
412+ # Create a small helper function to collect the builds for a given
413+ # list of build states:
414+ def collect_builds(*states):
415+ wanted = []
416+ for state in states:
417+ candidates = [build for build in builds
418+ if build.status == state]
419+ wanted.extend(candidates)
420+ return wanted
421+ failed = collect_builds(BuildStatus.FAILEDTOBUILD,
422+ BuildStatus.MANUALDEPWAIT,
423+ BuildStatus.CHROOTWAIT,
424+ BuildStatus.FAILEDTOUPLOAD)
425+ needsbuild = collect_builds(BuildStatus.NEEDSBUILD)
426+ building = collect_builds(BuildStatus.BUILDING,
427+ BuildStatus.UPLOADING)
428+ successful = collect_builds(BuildStatus.FULLYBUILT)
429+
430+ # Note: the BuildStatus DBItems are used here to summarize the
431+ # status of a set of builds:s
432+ if len(building) != 0:
433+ return {
434+ 'status': BuildSetStatus.BUILDING,
435+ 'builds': building,
436+ }
437+ # If there are no builds, this is a 'pending build request'
438+ # and needs building
439+ elif len(needsbuild) != 0 or len(builds) == 0:
440+ return {
441+ 'status': BuildSetStatus.NEEDSBUILD,
442+ 'builds': needsbuild,
443+ }
444+ elif len(failed) != 0:
445+ return {
446+ 'status': BuildSetStatus.FAILEDTOBUILD,
447+ 'builds': failed,
448+ }
449+ else:
450+ return {
451+ 'status': BuildSetStatus.FULLYBUILT,
452+ 'builds': successful,
453+ }
454+
455
456 @implementer(IOCIRecipeBuildRequest)
457 class OCIRecipeBuildRequest:
458diff --git a/lib/lp/oci/model/ocirecipejob.py b/lib/lp/oci/model/ocirecipejob.py
459index 3320cff..ac885d7 100644
460--- a/lib/lp/oci/model/ocirecipejob.py
461+++ b/lib/lp/oci/model/ocirecipejob.py
462@@ -4,6 +4,9 @@
463 """A build job for OCI Recipe."""
464
465 from __future__ import absolute_import, print_function, unicode_literals
466+from lp.buildmaster.model.processor import Processor
467+from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
468+
469
470 __metaclass__ = type
471 __all__ = [
472@@ -27,8 +30,10 @@ from zope.interface import (
473 implementer,
474 provider,
475 )
476+from zope.security.proxy import removeSecurityProxy
477
478 from lp.app.errors import NotFoundError
479+from lp.oci.interfaces.ocirecipebuild import OCIRecipeBuildRegistryUploadStatus
480 from lp.oci.interfaces.ocirecipejob import (
481 IOCIRecipeJob,
482 IOCIRecipeRequestBuildsJob,
483@@ -37,6 +42,8 @@ from lp.oci.interfaces.ocirecipejob import (
484 from lp.oci.model.ocirecipebuild import OCIRecipeBuild
485 from lp.registry.interfaces.person import IPersonSet
486 from lp.services.config import config
487+from lp.services.database.bulk import load_related
488+from lp.services.database.decoratedresultset import DecoratedResultSet
489 from lp.services.database.enumcol import EnumCol
490 from lp.services.database.interfaces import (
491 IMasterStore,
492@@ -51,6 +58,7 @@ from lp.services.job.runner import BaseRunnableJob
493 from lp.services.mail.sendmail import format_address_for_person
494 from lp.services.propertycache import cachedproperty
495 from lp.services.scripts import log
496+from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
497
498
499 class OCIRecipeJobType(DBEnumeratedType):
500@@ -196,11 +204,17 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
501 conditions.append(Job._status.is_in(statuses))
502 if job_ids is not None:
503 conditions.append(OCIRecipeJob.job_id.is_in(job_ids))
504- return IStore(OCIRecipeJob).find(
505- (OCIRecipeJob, Job),
506+ oci_jobs = IStore(OCIRecipeJob).find(
507+ OCIRecipeJob,
508 OCIRecipeJob.job_id == Job.id,
509 *conditions).order_by(Desc(OCIRecipeJob.job_id))
510
511+ def preload_jobs(rows):
512+ load_related(Job, rows, ["job_id"])
513+
514+ return DecoratedResultSet(
515+ oci_jobs, lambda oci_job: cls(oci_job), pre_iter_hook=preload_jobs)
516+
517 def getOperationDescription(self):
518 return "requesting builds of %s" % self.recipe
519
520@@ -244,9 +258,13 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
521 def builds(self):
522 """See `OCIRecipeRequestBuildsJob`."""
523 build_ids = self.metadata.get("builds")
524+ # Sort this by architecture/processor name, so it's consistent
525+ # when displayed
526 if build_ids:
527 return IStore(OCIRecipeBuild).find(
528- OCIRecipeBuild, OCIRecipeBuild.id.is_in(build_ids))
529+ OCIRecipeBuild, OCIRecipeBuild.id.is_in(build_ids),
530+ OCIRecipeBuild.processor_id == Processor.id).order_by(
531+ Desc(Processor.name))
532 else:
533 return EmptyResultSet()
534
535@@ -270,6 +288,50 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
536 def addUploadedManifest(self, build_id, manifest_info):
537 self.metadata["uploaded_manifests"][int(build_id)] = manifest_info
538
539+ def build_status(self):
540+ # This just returns a dict, but Zope is really helpful here
541+ status = removeSecurityProxy(
542+ getUtility(IOCIRecipeSet).getStatusSummaryForBuilds(
543+ list(self.builds)))
544+
545+ # This has a really long name!
546+ statusEnum = OCIRecipeBuildRegistryUploadStatus
547+
548+ # Set the pending upload status if either we're not done uploading,
549+ # or there was no upload requested in the first place (no push rules)
550+ if status['status'] == BuildSetStatus.FULLYBUILT:
551+ upload_status = [
552+ (x.registry_upload_status == statusEnum.UPLOADED or
553+ x.registry_upload_status == statusEnum.UNSCHEDULED)
554+ for x in status['builds']]
555+ if not all(upload_status):
556+ status['status'] = BuildSetStatus.FULLYBUILT_PENDING
557+
558+ # Add a flag for if we're expecting a registry upload
559+ status['upload_scheduled'] = any(
560+ x.registry_upload_status != statusEnum.UNSCHEDULED
561+ for x in status['builds'])
562+
563+ # Set the equivalent of BuildSetStatus, but for registry upload
564+ # If any of the builds have failed to upload
565+ if any(x.registry_upload_status == statusEnum.FAILEDTOUPLOAD
566+ for x in status['builds']):
567+ status['upload'] = statusEnum.FAILEDTOUPLOAD
568+ # If any of the builds are still waiting to upload
569+ elif any(x.registry_upload_status == statusEnum.PENDING
570+ for x in status['builds']):
571+ status['upload'] = statusEnum.PENDING
572+ else:
573+ status['upload'] = statusEnum.UPLOADED
574+
575+ # Get the longest date and whether any of them are estimated
576+ # for the summary of the builds
577+ dates = [x.date for x in self.builds if x.date]
578+ status['date'] = max(dates) if dates else None
579+ status['date_estimated'] = any(x.estimate for x in self.builds)
580+
581+ return status
582+
583 def run(self):
584 """See `IRunnableJob`."""
585 requester = self.requester
586diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
587index 5a1f3c1..569ea43 100644
588--- a/lib/lp/oci/templates/ocirecipe-index.pt
589+++ b/lib/lp/oci/templates/ocirecipe-index.pt
590@@ -8,6 +8,27 @@
591 >
592
593 <body>
594+
595+ <div metal:fill-slot="head_epilogue">
596+ <script type="text/javascript">
597+ LPJS.use('node', 'lp.app.widgets.expander', function(Y) {
598+ Y.on('domready', function() {
599+ var all_expanders = Y.all('.expander-icon');
600+ all_expanders.each(function(icon) {
601+ var base_id = icon.get('id').replace('-expander', '');
602+ console.log(base_id);
603+ var content_node = Y.one('#' + base_id);
604+ var animate_node = content_node.one('ul');
605+ var expander = new Y.lp.app.widgets.expander.Expander(
606+ icon, content_node, { animate_node: animate_node });
607+ expander.setUp();
608+ });
609+ });
610+ });
611+ </script>
612+</div>
613+
614+
615 <metal:registering fill-slot="registering">
616 Created by
617 <tal:registrant replace="structure context/registrant/fmt:link"/>
618@@ -102,55 +123,93 @@
619 </div>
620
621 <h2>Latest builds</h2>
622- <div tal:define="count context/pending_build_requests/count|nothing;
623- plural string: build requests;
624- singular string: build request;"
625- tal:condition="count">
626- You have <span tal:replace="count" /> pending
627- <tal:plural
628- metal:use-macro="context/@@+base-layout-macros/plural-message"/>.
629- The builds should start automatically soon.
630- </div>
631 <table id="latest-builds-listing" class="listing"
632 style="margin-bottom: 1em;">
633 <thead>
634 <tr>
635- <th>Status</th>
636+ <th>Build status</th>
637+ <th>Upload status</th>
638+ <th>When requested</th>
639 <th>When complete</th>
640- <th>Architecture</th>
641 </tr>
642 </thead>
643 <tbody>
644- <tal:recipe-builds repeat="item view/builds">
645- <tr tal:define="build item"
646- tal:attributes="id string:build-${build/id}">
647- <td tal:attributes="class string:build_status ${build/status/name}">
648- <span tal:replace="structure build/image:icon"/>
649- <a tal:content="build/status/title"
650- tal:attributes="href build/fmt:url"/>
651+ <tal:build-requests repeat="build_request view/build_requests">
652+ <tr tal:define="build_status build_request/build_status">
653+ <td tal:define="status_img python: view.getImageForStatus(build_status['status'])">
654+ <span tal:attributes="id string:request-${build_request/job_id}-expander" class="expander-icon" tal:condition="python: build_status['status'].name is not 'NEEDSBUILD'">&nbsp;</span>
655+ <img tal:attributes="title build_status/status/description;
656+ alt build_status/status/description;
657+ src status_img" />
658+ <span tal:content="build_status/status/description" />
659+
660 </td>
661- <td class="datebuilt">
662- <tal:date replace="build/date/fmt:displaydate"/>
663- <tal:estimate condition="build/estimate">
664+ <td>
665+ <tal:registry-upload tal:condition="build_status/upload_scheduled">
666+ <span tal:content="build_status/upload/title" />
667+ </tal:registry-upload>
668+ <tal:registry-upload tal:condition="not:build_status/upload_scheduled">
669+ <span tal:condition="python: 'FULLYBUILT' in build_status['status'].title">No registry upload requested.</span>
670+ <span tal:condition="python: 'FAILEDTOBUILD' in build_status['status'].title">No registry upload requested.</span>
671+ </tal:registry-upload>
672+ </td>
673+ <td>
674+ <span tal:content="build_request/date_created/fmt:displaydate" />
675+ </td>
676+ <td>
677+ <span tal:content="build_status/date/fmt:displaydate" />
678+ <tal:estimate condition="build_status/date_estimated">
679 (estimated)
680 </tal:estimate>
681-
682- <tal:build-log define="file build/log" tal:condition="file">
683- <a class="sprite download"
684- tal:attributes="href build/log_url">buildlog</a>
685- (<span tal:replace="file/content/filesize/fmt:bytes"/>)
686- </tal:build-log>
687 </td>
688+ </tr>
689+ <tr tal:define="build_status build_request/build_status" tal:attributes="id string:request-${build_request/job_id}" tal:condition="python: build_status['status'].name is not 'NEEDSBUILD'">
690 <td>
691- <!-- XXX cjwatson 2020-02-19: This should show a DAS
692- architecture tag rather than a processor name once we can
693- do that. -->
694- <a class="sprite distribution"
695- tal:define="processor build/processor"
696- tal:content="processor/name"/>
697+ <ul tal:repeat="build build_request/builds">
698+ <li style="padding-left: 22px;">
699+ <strong>
700+ <a class="sprite distribution"
701+ tal:define="processor build/processor"
702+ tal:content="processor/name"
703+ tal:attributes="href build/fmt:url"/>
704+ </strong>
705+ <span tal:define="file build/log" tal:condition="file">
706+ <a class="sprite download"
707+ tal:attributes="href build/log_url">buildlog</a>
708+ (<span tal:replace="file/content/filesize/fmt:bytes"/>)
709+ </span>
710+ <span tal:content="build/status/title" />
711+ </li>
712+ </ul>
713 </td>
714+ <td>
715+ <ul tal:condition="build_status/upload_scheduled" tal:repeat="build build_request/builds">
716+ <li style="padding-left: 22px;">
717+ <strong><a class="sprite distribution"
718+ tal:define="processor build/processor"
719+ tal:content="processor/name"/></strong>
720+ <span tal:content="build/registry_upload_status/title" />
721+ </li>
722+ </ul>
723+ </td>
724+ <td>
725+ </td>
726+ <td>
727+ <ul tal:repeat="build build_request/builds">
728+ <li style="padding-left: 22px;">
729+ <strong><a class="sprite distribution"
730+ tal:define="processor build/processor"
731+ tal:content="processor/name"/></strong>
732+ <span tal:content="build/date/fmt:displaydate" />
733+ <tal:estimate condition="build/estimate">
734+ (estimated)
735+ </tal:estimate>
736+ </li>
737+ </ul>
738+ </td>
739+
740 </tr>
741- </tal:recipe-builds>
742+ </tal:build-requests>
743 </tbody>
744 </table>
745 <p tal:condition="not: view/builds">

Subscribers

People subscribed via source and target branches

to status/vote changes: