← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Tom Wardill has proposed merging ~twom/launchpad:oci-add-registry-status-to-recipe-page into launchpad:master.

Commit message:
Add registry upload status to OCIRecipe:+index

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/401144

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-add-registry-status-to-recipe-page into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 21dad88..f55bf1d 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -43,6 +43,7 @@ from zope.schema import (
     ValidationError,
     )
 from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.browser.launchpadform import (
     action,
@@ -75,7 +76,11 @@ from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
     OCIRecipeFeatureDisabled,
     )
-from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.oci.interfaces.ocirecipebuild import (
+    IOCIRecipeBuildSet,
+    OCIRecipeBuildRegistryUploadStatus,
+    )
+from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
 from lp.oci.interfaces.ociregistrycredentials import (
     IOCIRegistryCredentialsSet,
     OCIRegistryCredentialsAlreadyExist,
@@ -101,6 +106,7 @@ from lp.services.webapp.breadcrumb import NameBreadcrumb
 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.soyuz.browser.archive import EnableProcessorsMixin
 from lp.soyuz.browser.build import get_build_by_id_str
+from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
 
 
 class OCIRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
@@ -308,6 +314,51 @@ class OCIRecipeView(LaunchpadView):
         distro = oci_project.distribution
         return bool(distro and distro.oci_registry_credentials)
 
+    def getImageForStatus(self, status):
+        image_map = {
+            BuildSetStatus.NEEDSBUILD: '/@@/build-needed',
+            BuildSetStatus.FULLYBUILT_PENDING: '/@@/build-success-publishing',
+            BuildSetStatus.FAILEDTOBUILD: '/@@/no',
+            BuildSetStatus.BUILDING: '/@@/processing',
+            }
+        return image_map.get(
+            status, '/@@/yes')
+
+    def _convertBuildJobToStatus(self, build_job):
+        recipe_set = getUtility(IOCIRecipeSet)
+        unscheduled_upload = OCIRecipeBuildRegistryUploadStatus.UNSCHEDULED
+        upload_status = build_job.registry_upload_status
+        # This is just a dict, but zope wraps it as RecipeSet is secured
+        status = removeSecurityProxy(
+                    recipe_set.getStatusSummaryForBuilds([build_job]))
+        # Add the registry job status
+        status["upload_scheduled"] = upload_status != unscheduled_upload
+        status["upload"] = upload_status
+        status["date"] = build_job.date
+        status["date_estimated"] = build_job.estimate
+        return {
+            "builds": [build_job],
+            "job_id": "build{}".format(build_job.id),
+            "date_created": build_job.date_created,
+            "date_finished": build_job.date_finished,
+            "build_status": status
+        }
+
+    def build_requests(self):
+        req_util = getUtility(IOCIRecipeRequestBuildsJobSource)
+        build_requests = list(req_util.findByOCIRecipe(self.context)[:10])
+
+        # It's possible that some recipes have builds that are older
+        # than the introduction of the async requestBuilds.
+        # In that case, convert the single build to a fake 'request build job'
+        # that has a single attached build.
+        if len(build_requests) < 10:
+            recipe = self.context
+            no_request_builds = recipe.completed_builds_without_build_request
+            for build in no_request_builds[:10 - len(build_requests)]:
+                build_requests.append(self._convertBuildJobToStatus(build))
+        return build_requests[:10]
+
 
 def builds_for_recipe(recipe):
     """A list of interesting builds.
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 7049d9c..3d60ed5 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -1255,25 +1255,53 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
                         "OCI recipe breadcrumb", "li",
                         text=re.compile(r"\srecipe-name\s")))))
 
+    def makeRecipe(self, processor_names, **kwargs):
+        recipe = self.factory.makeOCIRecipe(**kwargs)
+        processors_list = []
+        distroseries = self.factory.makeDistroSeries(
+            distribution=recipe.oci_project.distribution)
+        for proc_name in processor_names:
+            proc = getUtility(IProcessorSet).getByName(proc_name)
+            distro = self.factory.makeDistroArchSeries(
+                distroseries=distroseries, architecturetag=proc_name,
+                processor=proc)
+            distro.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+            processors_list.append(proc)
+        recipe.setProcessors(processors_list)
+        return recipe
+
     def test_index(self):
         oci_project = self.factory.makeOCIProject(
             pillar=self.distroseries.distribution)
-        oci_project_name = oci_project.name
         oci_project_display = oci_project.display_name
         [ref] = self.factory.makeGitRefs(
             owner=self.person, target=self.person, name="recipe-repository",
             paths=["refs/heads/master"])
-        recipe = self.makeOCIRecipe(
-            oci_project=oci_project, git_ref=ref, build_file="Dockerfile")
+        recipe = self.makeRecipe(
+            processor_names=["amd64", "386"],
+            build_file="Dockerfile", git_ref=ref,
+            oci_project=oci_project, registrant=self.person, owner=self.person)
+        build_request = recipe.requestBuilds(self.person)
+        builds = recipe.requestBuildsFromJob(self.person, build_request)
+        job = removeSecurityProxy(build_request).job
+        removeSecurityProxy(job).builds = builds
+
+        for build in builds:
+            removeSecurityProxy(build).updateStatus(
+                    BuildStatus.BUILDING, builder=None,
+                    date_started=build.date_created)
+            removeSecurityProxy(build).updateStatus(
+                BuildStatus.FULLYBUILT, builder=None,
+                date_finished=build.date_started + timedelta(minutes=30))
+
+        # We also need to account for builds that don't have a build_request
         build = self.makeBuild(
             recipe=recipe, status=BuildStatus.FULLYBUILT,
             duration=timedelta(minutes=30))
 
-        browser = self.getViewBrowser(build.recipe)
+        browser = self.getViewBrowser(build_request.recipe)
         login_person(self.person)
         self.assertTextMatchesExpressionIgnoreWhitespace("""\
-            %s OCI project
-            recipe-name
             .*
             OCI recipe information
             Owner: Test Person
@@ -1285,9 +1313,33 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             Official recipe:
             No
             Latest builds
-            Status When complete Architecture
-            Successfully built 30 minutes ago 386
-            """ % (oci_project_name, oci_project_display, recipe.build_path),
+            Build status
+            Upload status
+            When requested
+            When complete
+            All builds were built successfully.
+            No registry upload requested.
+            a moment ago
+            in 29 minutes
+            amd64
+            Successfully built
+            386
+            Successfully built
+            amd64
+            in 29 minutes
+            386
+            in 29 minutes
+            All builds were built successfully.
+            No registry upload requested.
+            1 hour ago
+            30 minutes ago
+            386
+            Successfully built
+            386
+            30 minutes ago
+            Recipe push rules
+            This OCI recipe has no push rules defined yet.
+            """ % (oci_project_display, recipe.build_path),
             extract_text(find_main_content(browser.contents)))
 
         # Check portlet on side menu.
@@ -1346,8 +1398,18 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             Official recipe:
             No
             Latest builds
-            Status When complete Architecture
-            Successfully built 30 minutes ago 386
+            Build status
+            Upload status
+            When requested
+            When complete
+            All builds were built successfully.
+            No registry upload requested.
+            1 hour ago
+            30 minutes ago
+            386
+            Successfully built
+            386
+            30 minutes ago
             """ % (oci_project_name, oci_project_display, build_path),
             self.getMainText(build.recipe))
 
@@ -1389,8 +1451,18 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             Official recipe:
             No
             Latest builds
-            Status When complete Architecture
-            Successfully built 30 minutes ago 386
+            Build status
+            Upload status
+            When requested
+            When complete
+            All builds were built successfully.
+            No registry upload requested.
+            1 hour ago
+            30 minutes ago
+            386
+            Successfully built
+            386
+            30 minutes ago
             """ % (oci_project_name, oci_project_display, build_path),
             main_text)
 
@@ -1401,8 +1473,20 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
         build.setLog(self.factory.makeLibraryFileAlias())
         self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
             Latest builds
-            Status When complete Architecture
-            Successfully built 30 minutes ago buildlog \(.*\) 386
+            Build status
+            Upload status
+            When requested
+            When complete
+            All builds were built successfully.
+            No registry upload requested.
+            1 hour ago
+            30 minutes ago
+            386
+            buildlog
+            \(.*\)
+            Successfully built
+            386
+            30 minutes ago
             """, self.getMainText(build.recipe))
 
     def test_index_no_builds(self):
@@ -1414,13 +1498,30 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
 
     def test_index_pending_build(self):
         # A pending build is listed as such.
-        build = self.makeBuild()
-        build.queueBuild()
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
+        [ref] = self.factory.makeGitRefs(
+            owner=self.person, target=self.person, name="recipe-repository",
+            paths=["refs/heads/master"])
+        recipe = self.makeRecipe(
+            processor_names=["amd64", "386"],
+            build_file="Dockerfile", git_ref=ref,
+            oci_project=oci_project, registrant=self.person, owner=self.person)
+        build_request = recipe.requestBuilds(self.person)
+        builds = recipe.requestBuildsFromJob(self.person, build_request)
+        job = removeSecurityProxy(build_request).job
+        removeSecurityProxy(job).builds = builds
+        #builds[0].queueBuild()
         self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
             Latest builds
-            Status When complete Architecture
-            Needs building in .* \(estimated\) 386
-            """, self.getMainText(build.recipe))
+            Build status
+            Upload status
+            When requested
+            When complete
+            There are some builds waiting to be built.
+            a moment ago
+            in .* \(estimated\)
+            """, self.getMainText(recipe))
 
     def test_index_request_builds_link(self):
         # Recipe owners get a link to allow requesting builds.
@@ -1531,24 +1632,6 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
             with dbuser(config.IOCIRecipeRequestBuildsJobSource.dbuser):
                 JobRunner(jobs).runAll()
 
-    def test_pending_build_requests_not_shown_if_absent(self):
-        self.recipe.requestBuilds(self.recipe.owner)
-        browser = self.getViewBrowser(self.recipe, user=self.person)
-        content = extract_text(find_main_content(browser.contents))
-        self.assertIn(
-            "You have 1 pending build request. "
-            "The builds should start automatically soon.",
-            content.replace("\n", " "))
-
-        # Run the build request, so we don't have any pending one. The
-        # message should be gone then.
-        self.runRequestBuildJobs()
-        browser = self.getViewBrowser(self.recipe, user=self.person)
-        content = extract_text(find_main_content(browser.contents))
-        self.assertNotIn(
-            "The builds should start automatically soon.",
-            content.replace("\n", " "))
-
     def test_request_builds_action(self):
         # Requesting a build creates pending builds.
         browser = self.getViewBrowser(
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index ae682ed..1cd81dd 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -280,6 +280,15 @@ class IOCIRecipeView(Interface):
         # Really IOCIRecipeBuild, patched in _schema_circular_imports.
         value_type=Reference(schema=Interface), readonly=True)
 
+    completed_builds_without_build_request = CollectionField(
+        title=_("Completed builds of this OCI recipe."),
+        description=_(
+            "Completed builds of this OCI recipe, sorted in descending "
+            "order of finishing that do no have a corresponding "
+            "build request"),
+        # Really IOCIRecipeBuild, patched in _schema_circular_imports.
+        value_type=Reference(schema=Interface), readonly=True)
+
     pending_builds = CollectionField(
         title=_("Pending builds of this OCI recipe."),
         description=_(
@@ -586,3 +595,25 @@ class IOCIRecipeSet(Interface):
         After this, any OCI recipes that previously used this repository
         will have no source and so cannot dispatch new builds.
         """
+
+    def getStatusSummaryForBuilds(builds):
+        """Return a summary of the build status for the given builds.
+
+        The returned summary includes a status, a description of
+        that status and the builds related to the status.
+
+        :param builds: A list of build records.
+        :type builds: ``list``
+        :return: A dict consisting of the build status summary for the
+            given builds. For example:
+                {
+                    'status': BuildSetStatus.FULLYBUILT,
+                    'builds': [build1, build2]
+                }
+            or, an example where there are currently some builds building:
+                {
+                    'status': BuildSetStatus.BUILDING,
+                    'builds':[build3]
+                }
+        :rtype: ``dict``.
+        """
diff --git a/lib/lp/oci/interfaces/ocirecipejob.py b/lib/lp/oci/interfaces/ocirecipejob.py
index 2b59faf..5fac142 100644
--- a/lib/lp/oci/interfaces/ocirecipejob.py
+++ b/lib/lp/oci/interfaces/ocirecipejob.py
@@ -90,6 +90,9 @@ class IOCIRecipeRequestBuildsJob(IRunnableJob):
         BuildRequest.
         """
 
+    def build_status():
+        """Return the status of the builds and the upload to a registry."""
+
 
 class IOCIRecipeRequestBuildsJobSource(IJobSource):
 
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index a56fa7d..3dcb917 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -5,6 +5,9 @@
 
 from __future__ import absolute_import, print_function, unicode_literals
 
+from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
+
+
 __metaclass__ = type
 __all__ = [
     'get_ocirecipe_privacy_filter',
@@ -732,6 +735,20 @@ class OCIRecipe(Storm, WebhookTargetMixin):
         return self._getBuilds(filter_term, order_by)
 
     @property
+    def completed_builds_without_build_request(self):
+        """See `IOCIRecipe`."""
+        filter_term = (
+            Not(OCIRecipeBuild.status.is_in(self._pending_states)),
+            OCIRecipeBuild.build_request_id == None)
+        order_by = (
+            NullsLast(Desc(Greatest(
+                OCIRecipeBuild.date_started,
+                OCIRecipeBuild.date_finished))),
+            Desc(OCIRecipeBuild.id))
+        return self._getBuilds(filter_term, order_by)
+
+
+    @property
     def pending_builds(self):
         """See `IOCIRecipe`."""
         filter_term = (OCIRecipeBuild.status.is_in(self._pending_states))
@@ -932,6 +949,50 @@ class OCIRecipeSet:
             if git_ref is not None:
                 recipe.git_ref = git_ref
 
+    def getStatusSummaryForBuilds(self, builds):
+        # Create a small helper function to collect the builds for a given
+        # list of build states:
+        def collect_builds(*states):
+            wanted = []
+            for state in states:
+                candidates = [build for build in builds
+                              if build.status == state]
+                wanted.extend(candidates)
+            return wanted
+        failed = collect_builds(BuildStatus.FAILEDTOBUILD,
+                                BuildStatus.MANUALDEPWAIT,
+                                BuildStatus.CHROOTWAIT,
+                                BuildStatus.FAILEDTOUPLOAD)
+        needsbuild = collect_builds(BuildStatus.NEEDSBUILD)
+        building = collect_builds(BuildStatus.BUILDING,
+                                  BuildStatus.UPLOADING)
+        successful = collect_builds(BuildStatus.FULLYBUILT)
+
+        # Note: the BuildStatus DBItems are used here to summarize the
+        # status of a set of builds:s
+        if len(building) != 0:
+            return {
+                'status': BuildSetStatus.BUILDING,
+                'builds': building,
+                }
+        # If there are no builds, this is a 'pending build request'
+        # and needs building
+        elif len(needsbuild) != 0 or len(builds) == 0:
+            return {
+                'status': BuildSetStatus.NEEDSBUILD,
+                'builds': needsbuild,
+                }
+        elif len(failed) != 0:
+            return {
+                'status': BuildSetStatus.FAILEDTOBUILD,
+                'builds': failed,
+                }
+        else:
+            return {
+                'status': BuildSetStatus.FULLYBUILT,
+                'builds': successful,
+                }
+
 
 @implementer(IOCIRecipeBuildRequest)
 class OCIRecipeBuildRequest:
diff --git a/lib/lp/oci/model/ocirecipejob.py b/lib/lp/oci/model/ocirecipejob.py
index 3320cff..36e1262 100644
--- a/lib/lp/oci/model/ocirecipejob.py
+++ b/lib/lp/oci/model/ocirecipejob.py
@@ -4,6 +4,9 @@
 """A build job for OCI Recipe."""
 
 from __future__ import absolute_import, print_function, unicode_literals
+from lp.buildmaster.model.processor import Processor
+from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
+
 
 __metaclass__ = type
 __all__ = [
@@ -27,8 +30,10 @@ from zope.interface import (
     implementer,
     provider,
     )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import NotFoundError
+from lp.oci.interfaces.ocirecipebuild import OCIRecipeBuildRegistryUploadStatus
 from lp.oci.interfaces.ocirecipejob import (
     IOCIRecipeJob,
     IOCIRecipeRequestBuildsJob,
@@ -37,6 +42,8 @@ from lp.oci.interfaces.ocirecipejob import (
 from lp.oci.model.ocirecipebuild import OCIRecipeBuild
 from lp.registry.interfaces.person import IPersonSet
 from lp.services.config import config
+from lp.services.database.bulk import load_related
+from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.enumcol import EnumCol
 from lp.services.database.interfaces import (
     IMasterStore,
@@ -51,6 +58,7 @@ from lp.services.job.runner import BaseRunnableJob
 from lp.services.mail.sendmail import format_address_for_person
 from lp.services.propertycache import cachedproperty
 from lp.services.scripts import log
+from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
 
 
 class OCIRecipeJobType(DBEnumeratedType):
@@ -196,11 +204,17 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
             conditions.append(Job._status.is_in(statuses))
         if job_ids is not None:
             conditions.append(OCIRecipeJob.job_id.is_in(job_ids))
-        return IStore(OCIRecipeJob).find(
-            (OCIRecipeJob, Job),
+        oci_jobs = IStore(OCIRecipeJob).find(
+            OCIRecipeJob,
             OCIRecipeJob.job_id == Job.id,
             *conditions).order_by(Desc(OCIRecipeJob.job_id))
 
+        def preload_jobs(rows):
+            load_related(Job, rows, ["job_id"])
+
+        return DecoratedResultSet(
+            oci_jobs, lambda oci_job: cls(oci_job), pre_iter_hook=preload_jobs)
+
     def getOperationDescription(self):
         return "requesting builds of %s" % self.recipe
 
@@ -244,9 +258,13 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
     def builds(self):
         """See `OCIRecipeRequestBuildsJob`."""
         build_ids = self.metadata.get("builds")
+        # Sort this by architecture/processor name, so it's consistent
+        # when displayed
         if build_ids:
             return IStore(OCIRecipeBuild).find(
-                OCIRecipeBuild, OCIRecipeBuild.id.is_in(build_ids))
+                OCIRecipeBuild, OCIRecipeBuild.id.is_in(build_ids),
+                OCIRecipeBuild.processor_id == Processor.id).order_by(
+                    Desc(Processor.name))
         else:
             return EmptyResultSet()
 
@@ -270,6 +288,47 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
     def addUploadedManifest(self, build_id, manifest_info):
         self.metadata["uploaded_manifests"][int(build_id)] = manifest_info
 
+    def build_status(self):
+        # This just returns a dict, but Zope is really helpful here
+        status = removeSecurityProxy(
+                getUtility(IOCIRecipeSet).getStatusSummaryForBuilds(
+                    list(self.builds)))
+
+        # This has a really long name!
+        statusEnum = OCIRecipeBuildRegistryUploadStatus
+
+        # Set the pending upload status if either we're not done uploading,
+        # or there was no upload requested in the first place (no push rules)
+        if status['status'] == BuildSetStatus.FULLYBUILT:
+            upload_status = [
+                (x.registry_upload_status == statusEnum.UPLOADED or
+                 x.registry_upload_status == statusEnum.UNSCHEDULED)
+                for x in status['builds']]
+            if not all(upload_status):
+                status['status'] = BuildSetStatus.FULLYBUILT_PENDING
+
+        # Add a flag for if we're expecting a registry upload
+        status['upload_scheduled'] = any(
+            x.registry_upload_status != statusEnum.UNSCHEDULED
+            for x in status['builds'])
+
+        # Set the equivalent of BuildSetStatus, but for registry upload
+        # If any of the builds have failed to upload
+        if any(x.registry_upload_status == statusEnum.FAILEDTOUPLOAD
+               for x in status['builds']):
+            status['upload'] = statusEnum.FAILEDTOUPLOAD
+        # If any of the builds are still waiting to upload
+        elif any(x.registry_upload_status == statusEnum.PENDING
+                 for x in status['builds']):
+            status['upload'] = statusEnum.PENDING
+        else:
+            status['upload'] = statusEnum.UPLOADED
+        dates = [x.date for x in self.builds if x.date]
+        status['date'] = max(dates) if dates else None
+        status['date_estimated'] = any(x.estimate for x in self.builds)
+
+        return status
+
     def run(self):
         """See `IRunnableJob`."""
         requester = self.requester
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index 5a1f3c1..569ea43 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -8,6 +8,27 @@
 >
 
 <body>
+
+  <div metal:fill-slot="head_epilogue">
+  <script type="text/javascript">
+    LPJS.use('node', 'lp.app.widgets.expander', function(Y) {
+      Y.on('domready', function() {
+        var all_expanders = Y.all('.expander-icon');
+        all_expanders.each(function(icon) {
+          var base_id = icon.get('id').replace('-expander', '');
+          console.log(base_id);
+          var content_node = Y.one('#' + base_id);
+          var animate_node = content_node.one('ul');
+          var expander = new Y.lp.app.widgets.expander.Expander(
+            icon, content_node, { animate_node: animate_node });
+          expander.setUp();
+        });
+      });
+    });
+  </script>
+</div>
+
+
   <metal:registering fill-slot="registering">
     Created by
       <tal:registrant replace="structure context/registrant/fmt:link"/>
@@ -102,55 +123,93 @@
     </div>
 
     <h2>Latest builds</h2>
-    <div tal:define="count context/pending_build_requests/count|nothing;
-                  plural string: build requests;
-                  singular string: build request;"
-         tal:condition="count">
-      You have <span tal:replace="count" /> pending
-      <tal:plural
-        metal:use-macro="context/@@+base-layout-macros/plural-message"/>.
-      The builds should start automatically soon.
-    </div>
     <table id="latest-builds-listing" class="listing"
            style="margin-bottom: 1em;">
       <thead>
         <tr>
-          <th>Status</th>
+          <th>Build status</th>
+          <th>Upload status</th>
+          <th>When requested</th>
           <th>When complete</th>
-          <th>Architecture</th>
         </tr>
       </thead>
       <tbody>
-        <tal:recipe-builds repeat="item view/builds">
-          <tr tal:define="build item"
-              tal:attributes="id string:build-${build/id}">
-            <td tal:attributes="class string:build_status ${build/status/name}">
-              <span tal:replace="structure build/image:icon"/>
-              <a tal:content="build/status/title"
-                 tal:attributes="href build/fmt:url"/>
+        <tal:build-requests repeat="build_request view/build_requests">
+          <tr tal:define="build_status build_request/build_status">
+            <td tal:define="status_img python: view.getImageForStatus(build_status['status'])">
+              <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>
+              <img tal:attributes="title build_status/status/description;
+                           alt build_status/status/description;
+                           src status_img" />
+              <span tal:content="build_status/status/description" />
+
             </td>
-            <td class="datebuilt">
-              <tal:date replace="build/date/fmt:displaydate"/>
-              <tal:estimate condition="build/estimate">
+            <td>
+              <tal:registry-upload tal:condition="build_status/upload_scheduled">
+                <span tal:content="build_status/upload/title" />
+              </tal:registry-upload>
+              <tal:registry-upload tal:condition="not:build_status/upload_scheduled">
+                <span tal:condition="python: 'FULLYBUILT' in build_status['status'].title">No registry upload requested.</span>
+                <span tal:condition="python: 'FAILEDTOBUILD' in build_status['status'].title">No registry upload requested.</span>
+              </tal:registry-upload>
+            </td>
+            <td>
+              <span tal:content="build_request/date_created/fmt:displaydate" />
+            </td>
+            <td>
+              <span tal:content="build_status/date/fmt:displaydate" />
+              <tal:estimate condition="build_status/date_estimated">
                 (estimated)
               </tal:estimate>
-
-              <tal:build-log define="file build/log" tal:condition="file">
-                <a class="sprite download"
-                   tal:attributes="href build/log_url">buildlog</a>
-                (<span tal:replace="file/content/filesize/fmt:bytes"/>)
-              </tal:build-log>
             </td>
+          </tr>
+          <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'">
             <td>
-              <!-- XXX cjwatson 2020-02-19: This should show a DAS
-                   architecture tag rather than a processor name once we can
-                   do that. -->
-              <a class="sprite distribution"
-                 tal:define="processor build/processor"
-                 tal:content="processor/name"/>
+              <ul tal:repeat="build build_request/builds">
+                <li  style="padding-left: 22px;">
+                  <strong>
+                    <a class="sprite distribution"
+                      tal:define="processor build/processor"
+                      tal:content="processor/name"
+                      tal:attributes="href build/fmt:url"/>
+                  </strong>
+                  <span tal:define="file build/log" tal:condition="file">
+                    <a class="sprite download"
+                      tal:attributes="href build/log_url">buildlog</a>
+                    (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+                  </span>
+                  <span tal:content="build/status/title" />
+                </li>
+              </ul>
             </td>
+            <td>
+              <ul tal:condition="build_status/upload_scheduled" tal:repeat="build build_request/builds">
+                <li  style="padding-left: 22px;">
+                  <strong><a class="sprite distribution"
+                    tal:define="processor build/processor"
+                    tal:content="processor/name"/></strong>
+                  <span tal:content="build/registry_upload_status/title" />
+                </li>
+              </ul>
+            </td>
+            <td>
+            </td>
+            <td>
+              <ul tal:repeat="build build_request/builds">
+                <li  style="padding-left: 22px;">
+                  <strong><a class="sprite distribution"
+                    tal:define="processor build/processor"
+                    tal:content="processor/name"/></strong>
+                  <span tal:content="build/date/fmt:displaydate" />
+                  <tal:estimate condition="build/estimate">
+                    (estimated)
+                  </tal:estimate>
+                </li>
+              </ul>
+            </td>
+
           </tr>
-        </tal:recipe-builds>
+        </tal:build-requests>
       </tbody>
     </table>
     <p tal:condition="not: view/builds">

Follow ups