← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-request-builds-ui into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-request-builds-ui into lp:launchpad with lp:~cjwatson/launchpad/snap-daily-builds-request-builds as a prerequisite.

Commit message:
Convert the snap web UI to use Snap.requestBuilds.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1770400 in Launchpad itself: "Support snapcraft architectures keyword"
  https://bugs.launchpad.net/launchpad/+bug/1770400

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-request-builds-ui/+merge/348299

The hard bit here was sorting out the JavaScript that auto-refreshes bits of Snap:+index.  I went for converting Snap.getBuildSummariesForSnapBuildIds into a more general Snap.getBuildSummaries that handles both build requests and builds, making it possible to extend the existing updater.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-request-builds-ui into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2017-09-01 12:57:34 +0000
+++ lib/lp/app/browser/configure.zcml	2018-06-20 16:53:58 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -591,6 +591,13 @@
       name="image"
       />
 
+  <adapter
+      for="lp.snappy.interfaces.snap.ISnapBuildRequest"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.SnapBuildRequestImageDisplayAPI"
+      name="image"
+      />
+
   <!-- TALES badges: namespace -->
 
   <adapter

=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py	2017-11-10 11:23:27 +0000
+++ lib/lp/app/browser/tales.py	2018-06-20 16:53:58 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Implementation of the lp: htmlform: fmt: namespaces in TALES."""
@@ -95,6 +95,7 @@
     )
 from lp.services.webapp.session import get_cookie_domain
 from lp.services.webapp.url import urlappend
+from lp.snappy.interfaces.snap import SnapBuildRequestStatus
 from lp.soyuz.enums import ArchivePurpose
 from lp.soyuz.interfaces.archive import (
     IArchive,
@@ -1164,6 +1165,39 @@
         return self.icon_template % (alt, title, source)
 
 
+class SnapBuildRequestImageDisplayAPI(ObjectImageDisplayAPI):
+    """Adapter for ISnapBuildRequest objects to an image.
+
+    Used for image:icon.
+    """
+    icon_template = (
+        '<img width="%(width)s" height="14" alt="%(alt)s" '
+        'title="%(title)s" src="%(src)s" />')
+
+    def icon(self):
+        """Return the appropriate <img> tag for the build request icon."""
+        icon_map = {
+            SnapBuildRequestStatus.PENDING: {'src': "/@@/processing"},
+            SnapBuildRequestStatus.FAILED: {
+                'src': "/@@/build-failed",
+                'width': "16",
+                },
+            SnapBuildRequestStatus.COMPLETED: {'src': "/@@/build-success"},
+            }
+
+        alt = '[%s]' % self._context.status.name
+        title = self._context.status.title
+        source = icon_map[self._context.status].get('src')
+        width = icon_map[self._context.status].get('width', '14')
+
+        return self.icon_template % {
+            'alt': alt,
+            'title': title,
+            'src': source,
+            'width': width,
+            }
+
+
 class BadgeDisplayAPI:
     """Adapter for IHasBadges to the images for the badges.
 

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/browser/snap.py	2018-06-20 16:53:58 +0000
@@ -248,11 +248,16 @@
         archive = Reference(IArchive, title=u'Source archive', required=True)
         distro_arch_series = List(
             Choice(vocabulary='SnapDistroArchSeries'),
-            title=u'Architectures', required=True)
+            title=u'Architectures', required=True,
+            description=(
+                u'If you do not explicitly select any architectures, then '
+                u'the snap package will be built for all architectures '
+                u'allowed by its configuration.'))
         pocket = Choice(
             title=u'Pocket', vocabulary=PackagePublishingPocket, required=True,
-            description=u'The package stream within the source distribution '
-                'series to use when building the snap package.')
+            description=(
+                u'The package stream within the source distribution series '
+                u'to use when building the snap package.'))
 
     custom_widget('archive', SnapArchiveWidget)
     custom_widget('distro_arch_series', LabeledMultiCheckBoxWidget)
@@ -271,18 +276,10 @@
         """See `LaunchpadFormView`."""
         return {
             'archive': self.context.distro_series.main_archive,
-            'distro_arch_series': self.context.getAllowedArchitectures(),
+            'distro_arch_series': [],
             'pocket': PackagePublishingPocket.UPDATES,
             }
 
-    def validate(self, data):
-        """See `LaunchpadFormView`."""
-        arches = data.get('distro_arch_series', [])
-        if not arches:
-            self.setFieldError(
-                'distro_arch_series',
-                "You need to select at least one architecture.")
-
     def requestBuild(self, data):
         """User action for requesting a number of builds.
 
@@ -309,12 +306,18 @@
 
     @action('Request builds', name='request')
     def request_action(self, action, data):
-        builds, informational = self.requestBuild(data)
+        if data['distro_arch_series']:
+            builds, informational = self.requestBuild(data)
+            already_pending = informational.get('already_pending')
+            notification_text = new_builds_notification_text(
+                builds, already_pending)
+            self.request.response.addNotification(notification_text)
+        else:
+            self.context.requestBuilds(
+                self.user, data['archive'], data['pocket'])
+            self.request.response.addNotification(
+                _('Builds will be dispatched soon.'))
         self.next_url = self.cancel_url
-        already_pending = informational.get('already_pending')
-        notification_text = new_builds_notification_text(
-            builds, already_pending)
-        self.request.response.addNotification(notification_text)
 
 
 class ISnapEditSchema(Interface):

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2018-06-20 16:53:58 +0000
@@ -26,6 +26,9 @@
 import responses
 import soupmatchers
 from testtools.matchers import (
+    AfterPreprocessing,
+    Equals,
+    Is,
     MatchesSetwise,
     MatchesStructure,
     )
@@ -33,6 +36,7 @@
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -64,6 +68,7 @@
     ISnapSet,
     SNAP_PRIVATE_FEATURE_FLAG,
     SNAP_TESTING_FLAGS,
+    SnapBuildRequestStatus,
     SnapPrivateFeatureDisabled,
     )
 from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
@@ -1427,6 +1432,9 @@
             Architectures:
             amd64
             i386
+            If you do not explicitly select any architectures, then the snap
+            package will be built for all architectures allowed by its
+            configuration.
             Pocket:
             Release
             Security
@@ -1446,12 +1454,13 @@
         self.assertRaises(
             Unauthorized, self.getViewBrowser, self.snap, "+request-builds")
 
-    def test_request_builds_action(self):
-        # Requesting a build creates pending builds.
+    def test_request_builds_with_architectures_action(self):
+        # Requesting a build with architectures selected creates pending
+        # builds.
         browser = self.getViewBrowser(
             self.snap, "+request-builds", user=self.person)
-        self.assertTrue(browser.getControl("amd64").selected)
-        self.assertTrue(browser.getControl("i386").selected)
+        browser.getControl("amd64").selected = True
+        browser.getControl("i386").selected = True
         browser.getControl("Request builds").click()
 
         login_person(self.person)
@@ -1467,44 +1476,74 @@
         self.assertContentEqual(
             [2510], set(build.buildqueue_record.lastscore for build in builds))
 
-    def test_request_builds_ppa(self):
-        # Selecting a different archive creates builds in that archive.
+    def test_request_builds_with_architectures_ppa(self):
+        # Selecting a different archive with architectures selected creates
+        # builds in that archive.
         ppa = self.factory.makeArchive(
             distribution=self.ubuntu, owner=self.person, name="snap-ppa")
         browser = self.getViewBrowser(
             self.snap, "+request-builds", user=self.person)
         browser.getControl("PPA").click()
         browser.getControl(name="field.archive.ppa").value = ppa.reference
-        self.assertTrue(browser.getControl("amd64").selected)
-        browser.getControl("i386").selected = False
+        browser.getControl("amd64").selected = True
+        self.assertFalse(browser.getControl("i386").selected)
         browser.getControl("Request builds").click()
 
         login_person(self.person)
         builds = self.snap.pending_builds
         self.assertEqual([ppa], [build.archive for build in builds])
 
-    def test_request_builds_no_architectures(self):
-        # Selecting no architectures causes a validation failure.
-        browser = self.getViewBrowser(
-            self.snap, "+request-builds", user=self.person)
-        browser.getControl("amd64").selected = False
-        browser.getControl("i386").selected = False
-        browser.getControl("Request builds").click()
-        self.assertIn(
-            "You need to select at least one architecture.",
-            extract_text(find_main_content(browser.contents)))
-
-    def test_request_builds_rejects_duplicate(self):
-        # A duplicate build request causes a notification.
+    def test_request_builds_with_architectures_rejects_duplicate(self):
+        # A duplicate build request with architectures selected causes a
+        # notification.
         self.snap.requestBuild(
             self.person, self.ubuntu.main_archive, self.distroseries["amd64"],
             PackagePublishingPocket.UPDATES)
         browser = self.getViewBrowser(
             self.snap, "+request-builds", user=self.person)
-        self.assertTrue(browser.getControl("amd64").selected)
-        self.assertTrue(browser.getControl("i386").selected)
+        browser.getControl("amd64").selected = True
+        browser.getControl("i386").selected = True
         browser.getControl("Request builds").click()
         main_text = extract_text(find_main_content(browser.contents))
         self.assertIn("1 new build has been queued.", main_text)
         self.assertIn(
             "An identical build is already pending for amd64.", main_text)
+
+    def test_request_builds_no_architectures_action(self):
+        # Requesting a build with no architectures selected creates a
+        # pending build request.
+        browser = self.getViewBrowser(
+            self.snap, "+request-builds", user=self.person)
+        self.assertFalse(browser.getControl("amd64").selected)
+        self.assertFalse(browser.getControl("i386").selected)
+        browser.getControl("Request builds").click()
+
+        login_person(self.person)
+        [request] = self.snap.pending_build_requests
+        self.assertThat(removeSecurityProxy(request), MatchesStructure(
+            snap=Equals(self.snap),
+            status=Equals(SnapBuildRequestStatus.PENDING),
+            error_message=Is(None),
+            builds=AfterPreprocessing(list, Equals([])),
+            archive=Equals(self.ubuntu.main_archive),
+            _job=MatchesStructure(
+                requester=Equals(self.person),
+                pocket=Equals(PackagePublishingPocket.UPDATES),
+                channels=Is(None))))
+
+    def test_request_builds_no_architectures_ppa(self):
+        # Selecting a different archive with no architectures selected
+        # creates a build request targeting that archive.
+        ppa = self.factory.makeArchive(
+            distribution=self.ubuntu, owner=self.person, name="snap-ppa")
+        browser = self.getViewBrowser(
+            self.snap, "+request-builds", user=self.person)
+        browser.getControl("PPA").click()
+        browser.getControl(name="field.archive.ppa").value = ppa.reference
+        self.assertFalse(browser.getControl("amd64").selected)
+        self.assertFalse(browser.getControl("i386").selected)
+        browser.getControl("Request builds").click()
+
+        login_person(self.person)
+        [request] = self.snap.pending_build_requests
+        self.assertEqual(ppa, request.archive)

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/interfaces/snap.py	2018-06-20 16:53:58 +0000
@@ -307,6 +307,11 @@
         value_type=Reference(schema=Interface),
         required=True, readonly=True))
 
+    archive = Reference(
+        IArchive,
+        title=u"The source archive for builds produced by this request",
+        required=True, readonly=True)
+
 
 class ISnapView(Interface):
     """`ISnap` attributes that require launchpad.View permission."""
@@ -435,21 +440,46 @@
         :return: `ISnapBuildRequest`.
         """
 
+    pending_build_requests = List(
+        value_type=Reference(ISnapBuildRequest),
+        title=u"Pending build requests", required=True, readonly=True)
+
+    # XXX cjwatson 2018-06-20: Deprecated as an exported method; can become
+    # an internal helper method once production JavaScript no longer uses
+    # it.
     @operation_parameters(
         snap_build_ids=List(
-            title=_("A list of snap build ids."),
-            value_type=Int()))
+            title=_("A list of snap build IDs."), value_type=Int()))
     @export_read_operation()
     @operation_for_version("devel")
     def getBuildSummariesForSnapBuildIds(snap_build_ids):
         """Return a dictionary containing a summary of the build statuses.
 
-        :param snap_build_ids: A list of snap build ids.
+        :param snap_build_ids: A list of snap build IDs.
         :type source_ids: ``list``
         :return: A dict consisting of the overall status summaries for the
             given snap builds.
         """
 
+    @operation_parameters(
+        request_ids=List(
+            title=_("A list of snap build request IDs."), value_type=Int(),
+            required=False),
+        build_ids=List(
+            title=_("A list of snap build IDs."), value_type=Int(),
+            required=False))
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getBuildSummaries(request_ids=None, build_ids=None):
+        """Return a dictionary containing a summary of build information.
+
+        :param request_ids: A list of snap build request IDs.
+        :param build_ids: A list of snap build IDs.
+        :return: A dict of {"requests", "builds"}, consisting of the overall
+            status summaries for the given snap build requests and snap
+            builds respectively.
+        """
+
     builds = exported(doNotSnapshot(CollectionField(
         title=_("All builds of this snap package."),
         description=_(

=== modified file 'lib/lp/snappy/interfaces/snapjob.py'
--- lib/lp/snappy/interfaces/snapjob.py	2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/interfaces/snapjob.py	2018-06-20 16:53:58 +0000
@@ -96,6 +96,16 @@
             for these builds.
         """
 
+    def findBySnap(snap, statuses=None, job_ids=None):
+        """Find jobs for a snap.
+
+        :param snap: A snap package to search for.
+        :param statuses: An optional iterable of `JobStatus`es to search for.
+        :param job_ids: An optional iterable of job IDs to search for.
+        :return: A sequence of `SnapRequestBuildsJob`s with the specified
+            snap.
+        """
+
     def getBySnapAndID(snap, job_id):
         """Get a job by snap and job ID.
 
@@ -103,3 +113,10 @@
         :raises: `NotFoundError` if there is no job with the specified snap
             and ID, or its `job_type` is not `SnapJobType.REQUEST_BUILDS`.
         """
+
+    def findBuildsForJobs(jobs):
+        """Find builds resulting from an iterable of `SnapRequestBuildJob`s.
+
+        :return: A dictionary mapping `SnapRequestBuildJob` IDs to lists of
+            their resulting builds.
+        """

=== modified file 'lib/lp/snappy/javascript/snap.update_build_statuses.js'
--- lib/lp/snappy/javascript/snap.update_build_statuses.js	2017-08-31 13:35:55 +0000
+++ lib/lp/snappy/javascript/snap.update_build_statuses.js	2018-06-20 16:53:58 +0000
@@ -1,4 +1,4 @@
-/* Copyright 2016 Canonical Ltd.  This software is licensed under the
+/* Copyright 2016-2018 Canonical Ltd.  This software is licensed under the
  * GNU Affero General Public License version 3 (see the file LICENSE).
  *
  * The lp.snappy.snap.update_build_statuses module uses the
@@ -15,11 +15,113 @@
     module.pending_states = [
         "NEEDSBUILD", "BUILDING", "UPLOADING", "CANCELLING"];
 
+    module.update_date_built = function(node, build_summary) {
+        node.set("innerHTML", build_summary.when_complete);
+        if (build_summary.when_complete_estimate) {
+            node.appendChild(document.createTextNode(' (estimated)'));
+        }
+        if (build_summary.build_log_url !== null) {
+            var new_link = Y.Node.create(
+                '<a class="sprite download">buildlog</a>');
+            new_link.setAttribute('href', build_summary.build_log_url);
+            node.appendChild(document.createTextNode(' '));
+            node.appendChild(new_link);
+            if (build_summary.build_log_size !== null) {
+                node.appendChild(document.createTextNode(' '));
+                node.append("(" + build_summary.build_log_size + " bytes)");
+            }
+        }
+    };
+
     module.domUpdate = function(table, data_object) {
-        Y.each(data_object, function(build_summary, build_id) {
+        var tbody = table.one('tbody');
+        if (tbody === null) {
+            return;
+        }
+        var tbody_changed = false;
+
+        Y.each(data_object['requests'], function(request_summary, request_id) {
+            var tr_elem = tbody.one('tr#request-' + request_id);
+            if (tr_elem === null) {
+                return;
+            }
+
+            if (request_summary['status'] === 'FAILED') {
+                // XXX cjwatson 2018-06-18: Maybe we should show the error
+                // message in this case, but we don't show non-pending
+                // requests in the non-JS case, so it's not clear where
+                // would be appropriate.
+                tr_elem.remove();
+                tbody_changed = true;
+                return;
+            } else if (request_summary['status'] === 'COMPLETED') {
+                // Insert rows for the new builds.
+                Y.Array.each(request_summary['builds'],
+                             function(build_summary) {
+                    // Construct the new row.
+                    var new_row = Y.Node.create('<tr/>')
+                        .set('id', 'build-' + build_summary.id);
+                    var new_build_status = Y.Node.create('<td/>');
+                    new_build_status.append('<img/>');
+                    new_build_status.appendChild('<a/>')
+                        .set('href', build_summary.self_link);
+                    Y.lp.buildmaster.buildstatus.update_build_status(
+                        new_build_status, build_summary);
+                    new_row.append(new_build_status);
+                    var new_datebuilt = Y.Node.create(
+                        '<td class="datebuilt"/>');
+                    if (build_summary.when_complete !== null) {
+                        module.update_date_built(new_datebuilt, build_summary);
+                    }
+                    new_row.append(new_datebuilt);
+                    var new_arch = Y.Node.create('<td/>');
+                    var new_arch_link = Y.Node.create(
+                        '<a class="sprite distribution"/>');
+                    new_arch_link.set(
+                        'href', build_summary.distro_arch_series_link);
+                    new_arch_link.appendChild(document.createTextNode(
+                        build_summary.architecture_tag));
+                    new_arch.append(new_arch_link);
+                    new_row.append(new_arch);
+                    new_row.append(
+                        '<td>' + build_summary.archive_link + '</td>');
+
+                    // Insert the new row, maintaining descending-ID sorted
+                    // order.
+                    var tr_next = null;
+                    tbody.get('children').some(function(tr) {
+                        var tr_id = tr.get('id');
+                        if (tr_id !== null &&
+                                tr_id.substr(0, 6) === 'build-') {
+                            var build_id = parseInt(
+                                tr_id.replace('build-', ''), 10);
+                            if (!isNaN(build_id) &&
+                                    build_id < build_summary.id) {
+                                tr_next = tr;
+                                return true;
+                            }
+                        }
+                        return false;
+                    });
+                    tbody.insert(new_row, tr_next);
+                });
+
+                // Remove the completed build request row.
+                tr_elem.remove();
+                tbody_changed = true;
+                return;
+            }
+        });
+
+        if (tbody_changed) {
+            var anim = Y.lp.anim.green_flash({node: tbody});
+            anim.run();
+        }
+
+        Y.each(data_object['builds'], function(build_summary, build_id) {
             var ui_changed = false;
 
-            var tr_elem = Y.one("tr#build-" + build_id);
+            var tr_elem = tbody.one("tr#build-" + build_id);
             if (tr_elem === null) {
                 return;
             }
@@ -38,25 +140,7 @@
 
             if (build_summary.when_complete !== null) {
                 ui_changed = true;
-                td_datebuilt.set("innerHTML", build_summary.when_complete);
-                if (build_summary.when_complete_estimate) {
-                    td_datebuilt.appendChild(
-                        document.createTextNode(' (estimated)'));
-                }
-                if (build_summary.build_log_url !== null) {
-                    var new_link = Y.Node.create(
-                        '<a class="sprite download">buildlog</a>');
-                    new_link.setAttribute(
-                        'href', build_summary.build_log_url);
-                    td_datebuilt.appendChild(document.createTextNode(' '));
-                    td_datebuilt.appendChild(new_link);
-                    if (build_summary.build_log_size !== null) {
-                        td_datebuilt.appendChild(
-                            document.createTextNode(' '));
-                        td_datebuilt.append(
-                            "(" + build_summary.build_log_size + " bytes)");
-                    }
-                }
+                module.update_date_built(td_datebuilt, build_summary);
             }
 
             if (ui_changed) {
@@ -67,32 +151,46 @@
     };
 
     module.parameterEvaluator = function(table_node) {
-        var td_list = table_node.all('td.build_status');
-        var pending = td_list.filter("." + module.pending_states.join(",."));
-        if (pending.size() === 0) {
+        var td_request_list = table_node.all('td.request_status');
+        var pending_requests = td_request_list.filter('.PENDING');
+        var td_build_list = table_node.all('td.build_status');
+        var pending_builds = td_build_list.filter(
+            "." + module.pending_states.join(",."));
+        if (pending_requests.size() === 0 && pending_builds.size() === 0) {
             return null;
         }
 
-        var snap_build_ids = [];
-        Y.each(pending, function(node) {
-            var elem_id = node.ancestor().get('id');
-            var snap_build_id = elem_id.replace('build-', '');
-            snap_build_ids.push(snap_build_id);
-        });
-
-        return {snap_build_ids: snap_build_ids};
+        var request_ids = [];
+        Y.each(pending_requests, function(node) {
+            var elem_id = node.ancestor().get('id');
+            var request_id = elem_id.replace('request-', '');
+            request_ids.push(request_id);
+        });
+
+        var build_ids = [];
+        Y.each(pending_builds, function(node) {
+            var elem_id = node.ancestor().get('id');
+            var build_id = elem_id.replace('build-', '');
+            build_ids.push(build_id);
+        });
+
+        return {request_ids: request_ids, build_ids: build_ids};
     };
 
     module.stopUpdatesCheck = function(table_node) {
-        // Stop updating when there aren't any builds to update
-        var td_list = table_node.all('td.build_status');
-        var pending = td_list.filter("." + module.pending_states.join(",."));
-        return (pending.size() === 0);
+        // Stop updating when there aren't any build requests or builds to
+        // update.
+        var td_request_list = table_node.all('td.request_status');
+        var pending_requests = td_request_list.filter('.PENDING');
+        var td_build_list = table_node.all('td.build_status');
+        var pending_builds = td_build_list.filter(
+            "." + module.pending_states.join(",."));
+        return pending_requests.size() === 0 && pending_builds.size() === 0;
     };
 
     module.config = {
         uri: null,
-        api_method_name: 'getBuildSummariesForSnapBuildIds',
+        api_method_name: 'getBuildSummaries',
         lp_client: null,
         domUpdateFunction: module.domUpdate,
         parameterEvaluatorFunction: module.parameterEvaluator,

=== modified file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html'
--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html	2017-08-31 13:35:55 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html	2018-06-20 16:53:58 +0000
@@ -1,6 +1,6 @@
 <!DOCTYPE html>
 <!--
-Copyright 2016 Canonical Ltd.  This software is licensed under the
+Copyright 2016-2018 Canonical Ltd.  This software is licensed under the
 GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -62,19 +62,10 @@
           </tr>
         </thead>
         <tbody>
-            <tr id="build-1">
-              <td class="build_status NEEDSBUILD">
-                <img width="14" height="14" alt="[NEEDSBUILD]" title="Needs building" src="/@@/build-needed" />
-                <a href="snap/+build/1">Needs building</a>
-              </td>
-              <td class="datebuilt">
-                in 1 minute (estimated)
-              </td>
-              <td>
-                <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
-              </td>
-              <td>
-                <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
+            <tr id="request-1">
+              <td class="request_status PENDING">
+                <img width="14" height="14" alt="[PENDING]" title="Pending" src="/@@/build-needed" />
+                Pending build request
               </td>
             </tr>
             <tr id="build-2">
@@ -94,6 +85,21 @@
                 <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
               </td>
             </tr>
+            <tr id="build-1">
+              <td class="build_status NEEDSBUILD">
+                <img width="14" height="14" alt="[NEEDSBUILD]" title="Needs building" src="/@@/build-needed" />
+                <a href="snap/+build/1">Needs building</a>
+              </td>
+              <td class="datebuilt">
+                in 1 minute (estimated)
+              </td>
+              <td>
+                <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
+              </td>
+              <td>
+                <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
+              </td>
+            </tr>
         </tbody>
       </table>
 

=== modified file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js'
--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js	2017-08-31 13:35:55 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js	2018-06-20 16:53:58 +0000
@@ -1,4 +1,4 @@
-/* Copyright 2016 Canonical Ltd.  This software is licensed under the
+/* Copyright 2016-2018 Canonical Ltd.  This software is licensed under the
  * GNU Affero General Public License version 3 (see the file LICENSE). */
 
 YUI.add('lp.snappy.snap.update_build_statuses.test', function (Y) {
@@ -10,13 +10,42 @@
         name: 'lp.snappy.snap.update_build_statuses_tests',
 
         setUp: function () {
-            this.table = Y.one('table#latest-builds-listing');
-            this.tr_build_1 = Y.one('tr#build-1');
+            // Clone the table from the test data so that we can reliably
+            // restore it.
+            this.table = Y.one('table#latest-builds-listing').cloneNode(true);
+            this.tbody = this.table.one('tbody');
+            this.tr_request_1 = this.tbody.one('tr#request-1');
+            this.tr_build_1 = this.tbody.one('tr#build-1');
             this.td_status = this.tr_build_1.one('td.build_status');
             this.td_datebuilt = this.tr_build_1.one("td.datebuilt");
-            this.td_status_class = this.td_status.getAttribute("class");
-            this.td_status_img = this.td_status.one("img");
-            this.td_status_a = this.td_status.one("a");
+        },
+
+        assert_node_matches: function(expected, node) {
+            Y.each(expected, function(value, key) {
+                if (key === "tag") {
+                    Y.Assert.areEqual(
+                        value, node.get("tagName").toLowerCase());
+                } else if (key === "attrs") {
+                    Y.each(value, function(attr_value, attr_key) {
+                        Y.Assert.areEqual(
+                            attr_value, node.getAttribute(attr_key));
+                    });
+                } else if (key === "text") {
+                    Y.Assert.areEqual(value, node.get("text").trim());
+                } else if (key === "children") {
+                    var children = [];
+                    node.get("children").each(function(child) {
+                        children.push(child);
+                    });
+                    Y.Array.each(Y.Array.zip(value, children), function(item) {
+                        Y.Assert.isObject(item[0]);
+                        Y.Assert.isObject(item[1]);
+                        this.assert_node_matches(item[0], item[1]);
+                    }, this);
+                } else {
+                    Y.Assert.fail("unhandled key " + key);
+                }
+            }, this);
         },
 
         test_dom_updater_plugin_attached: function() {
@@ -32,167 +61,354 @@
 
         test_parameter_evaluator: function() {
             // parameterEvaluator should return an object with the ids of
-            // builds in pending states.
+            // build requests and builds in pending states.
             var params = module.parameterEvaluator(this.table);
             Y.lp.testing.assert.assert_equal_structure(
-                {snap_build_ids: ["1"]}, params);
+                {request_ids: ["1"], build_ids: ["1"]}, params);
         },
 
         test_parameter_evaluator_empty: function() {
             // parameterEvaluator should return empty if no builds remaining
             // in pending states.
+            this.tr_request_1.remove();
             this.td_status.setAttribute("class", "build_status FULLYBUILT");
             var params = module.parameterEvaluator(this.table);
             Y.Assert.isNull(params);
-            // reset td class to the original value
-            this.td_status.setAttribute("class", this.td_status_class);
         },
 
         test_stop_updates_check: function() {
-            // stopUpdatesCheck should return false if pending builds exist.
-            Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
-            // stopUpdatesCheck should return true if no pending builds exist.
+            // stopUpdatesCheck should return false if pending build
+            // requests or pending builds exist.
+            Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
+            this.tr_request_1.one('td.request_status')
+                .setAttribute('class', 'request_status COMPLETED');
+            Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
+            // stopUpdatesCheck should return true if no pending build
+            // requests or pending builds exist.
             this.td_status.setAttribute("class", "build_status FULLYBUILT");
             Y.Assert.isTrue(module.stopUpdatesCheck(this.table));
+            this.tr_request_1.remove();
+            Y.Assert.isTrue(module.stopUpdatesCheck(this.table));
             for (var i = 0; i < module.pending_states.length; i++) {
                 this.td_status.setAttribute(
                     "class", "build_status " + module.pending_states[i]);
                 Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
             }
-            // reset td class to the original value
-            this.td_status.setAttribute("class", this.td_status_class);
-        },
-
-        test_update_build_status_dom_building: function() {
-            var original_a_href = this.td_status_a.get("href");
-            var data = {
-                "1": {
-                    "status": "BUILDING",
-                    "build_log_url": null,
-                    "when_complete_estimate": true,
-                    "buildstate": "Currently building",
-                    "build_log_size": null,
-                    "when_complete": "in 1 minute"
-                }
-            };
-            module.domUpdate(this.table, data);
-            Y.Assert.areEqual(
-                "build_status BUILDING", this.td_status.getAttribute("class"));
-            Y.Assert.areEqual(
-                "Currently building", this.td_status.get("text").trim());
-            Y.Assert.areEqual("[BUILDING]", this.td_status_img.get("alt"));
-            Y.Assert.areEqual(
-                "Currently building", this.td_status_img.get("title"));
-            Y.Assert.areEqual(
-                "file:///@@/processing", this.td_status_img.get("src"));
-            Y.Assert.areEqual("14", this.td_status_img.get("width"));
-            Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
-        },
-
-        test_update_build_status_dom_building: function() {
-            var original_a_href = this.td_status_a.get("href");
-            var data = {
-                "1": {
-                    "status": "BUILDING",
-                    "build_log_url": null,
-                    "when_complete_estimate": true,
-                    "buildstate": "Currently building",
-                    "build_log_size": null,
-                    "when_complete": "in 1 minute"
-                }
-            };
-            module.domUpdate(this.table, data);
-            Y.Assert.areEqual(
-                "build_status BUILDING", this.td_status.getAttribute("class"));
-            Y.Assert.areEqual(
-                "Currently building", this.td_status.get("text").trim());
-            Y.Assert.areEqual("[BUILDING]", this.td_status_img.get("alt"));
-            Y.Assert.areEqual(
-                "Currently building", this.td_status_img.get("title"));
-            Y.Assert.areEqual(
-                "file:///@@/processing", this.td_status_img.get("src"));
-            Y.Assert.areEqual("14", this.td_status_img.get("width"));
-            Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
+        },
+
+        test_update_build_request_status_dom_completed: function() {
+            var data = {
+                "requests": {
+                    "1": {
+                        "status": "COMPLETED",
+                        "error_message": null,
+                        "builds": [
+                            {
+                                "self_link": "/~max/+snap/snap/+build/3",
+                                "id": 3,
+                                "distro_arch_series_link":
+                                    "/ubuntu/hoary/amd64",
+                                "architecture_tag": "amd64",
+                                "archive_link":
+                                    '<a href="/ubuntu" ' +
+                                    'class="sprite distribution">Primary ' +
+                                    'Archive for Ubuntu Linux</a>',
+                                "status": "NEEDSBUILD",
+                                "build_log_url": null,
+                                "when_complete_estimate": false,
+                                "buildstate": "Needs building",
+                                "build_log_size": null,
+                                "when_complete": null
+                            },
+                            {
+                                "self_link": "/~max/+snap/snap/+build/4",
+                                "id": 4,
+                                "distro_arch_series_link":
+                                    "/ubuntu/hoary/i386",
+                                "architecture_tag": "i386",
+                                "archive_link":
+                                    '<a href="/ubuntu" ' +
+                                    'class="sprite distribution">Primary ' +
+                                    'Archive for Ubuntu Linux</a>',
+                                "status": "BUILDING",
+                                "build_log_url": null,
+                                "when_complete_estimate": true,
+                                "buildstate": "Currently building",
+                                "build_log_size": null,
+                                "when_complete": "in 1 minute"
+                            }
+                        ]
+                    }
+                },
+                "builds": {}
+            };
+            module.domUpdate(this.table, data);
+            Y.ArrayAssert.itemsAreEqual(
+                ["build-4", "build-3", "build-2", "build-1"],
+                this.tbody.get("children").get("id"));
+            this.assert_node_matches({
+                "tag": "tr",
+                "attrs": {"id": "build-3"},
+                "children": [
+                    {
+                        "tag": "td",
+                        "attrs": {"class": "build_status NEEDSBUILD"},
+                        "children": [
+                            {
+                                "tag": "img",
+                                "attrs": {
+                                    "alt": "[NEEDSBUILD]",
+                                    "title": "Needs building",
+                                    "src": "/@@/build-needed",
+                                    "width": "14"
+                                }
+                            },
+                            {
+                                "tag": "a",
+                                "attrs": {"href": "/~max/+snap/snap/+build/3"},
+                                "text": "Needs building"
+                            }
+                        ]
+                    },
+                    {
+                        "tag": "td",
+                        "attrs": {"class": "datebuilt"},
+                        "text": "",
+                        "children": []
+                    },
+                    {
+                        "tag": "td",
+                        "children": [{
+                            "tag": "a",
+                            "attrs": {
+                                "class": "sprite distribution",
+                                "href": "/ubuntu/hoary/amd64"
+                            },
+                            "text": "amd64"
+                        }]
+                    },
+                    {
+                        "tag": "td",
+                        "children": [{
+                            "tag": "a",
+                            "attrs": {
+                                "class": "sprite distribution",
+                                "href": "/ubuntu"
+                            },
+                            "text": "Primary Archive for Ubuntu Linux"
+                        }]
+                    }
+                ]
+            }, this.tbody.one("tr#build-3"));
+            this.assert_node_matches({
+                "tag": "tr",
+                "attrs": {"id": "build-4"},
+                "children": [
+                    {
+                        "tag": "td",
+                        "attrs": {"class": "build_status BUILDING"},
+                        "children": [
+                            {
+                                "tag": "img",
+                                "attrs": {
+                                    "alt": "[BUILDING]",
+                                    "title": "Currently building",
+                                    "src": "/@@/processing",
+                                    "width": "14"
+                                }
+                            },
+                            {
+                                "tag": "a",
+                                "attrs": {"href": "/~max/+snap/snap/+build/4"},
+                                "text": "Currently building"
+                            }
+                        ]
+                    },
+                    {
+                        "tag": "td",
+                        "attrs": {"class": "datebuilt"},
+                        "text": "in 1 minute (estimated)",
+                        "children": []
+                    },
+                    {
+                        "tag": "td",
+                        "children": [{
+                            "tag": "a",
+                            "attrs": {
+                                "class": "sprite distribution",
+                                "href": "/ubuntu/hoary/i386"
+                            },
+                            "text": "i386"
+                        }]
+                    },
+                    {
+                        "tag": "td",
+                        "children": [{
+                            "tag": "a",
+                            "attrs": {
+                                "class": "sprite distribution",
+                                "href": "/ubuntu"
+                            },
+                            "text": "Primary Archive for Ubuntu Linux"
+                        }]
+                    }
+                ]
+            }, this.tbody.one("tr#build-4"));
+        },
+
+        test_update_build_request_status_dom_failed: function() {
+            var data = {
+                "requests": {
+                    "1": {
+                        "status": "FAILED",
+                        "error_message": "Something went wrong",
+                        "builds": []
+                    }
+                },
+                "builds": {}
+            };
+            module.domUpdate(this.table, data);
+            Y.ArrayAssert.itemsAreEqual(
+                ["build-2", "build-1"], this.tbody.get("children").get("id"));
+        },
+
+        test_update_build_status_dom_building: function() {
+            var original_a_href = this.td_status.one("a").getAttribute("href");
+            var data = {
+                "requests": {},
+                "builds": {
+                    "1": {
+                        "status": "BUILDING",
+                        "build_log_url": null,
+                        "when_complete_estimate": true,
+                        "buildstate": "Currently building",
+                        "build_log_size": null,
+                        "when_complete": "in 1 minute"
+                    }
+                }
+            };
+            module.domUpdate(this.table, data);
+            this.assert_node_matches({
+                "attrs": {"class": "build_status BUILDING"},
+                "text": "Currently building",
+                "children": [
+                    {
+                        "tag": "img",
+                        "attrs": {
+                            "alt": "[BUILDING]",
+                            "title": "Currently building",
+                            "src": "/@@/processing",
+                            "width": "14"
+                        }
+                    },
+                    {
+                        "tag": "a",
+                        "attrs": {"href": original_a_href}
+                    }
+                ]
+            }, this.td_status);
         },
 
         test_update_build_status_dom_failedtobuild: function() {
-            var original_a_href = this.td_status_a.get("href");
+            var original_a_href = this.td_status.one("a").getAttribute("href");
             var data = {
-                "1": {
-                    "status": "FAILEDTOBUILD",
-                    "build_log_url": null,
-                    "when_complete_estimate": false,
-                    "buildstate": "Failed to build",
-                    "build_log_size": null,
-                    "when_complete": "1 minute ago"
+                "requests": {},
+                "builds": {
+                    "1": {
+                        "status": "FAILEDTOBUILD",
+                        "build_log_url": null,
+                        "when_complete_estimate": false,
+                        "buildstate": "Failed to build",
+                        "build_log_size": null,
+                        "when_complete": "1 minute ago"
+                    }
                 }
             };
             module.domUpdate(this.table, data);
-            Y.Assert.areEqual(
-                "build_status FAILEDTOBUILD",
-                this.td_status.getAttribute("class"));
-            Y.Assert.areEqual(
-                "Failed to build", this.td_status.get("text").trim());
-            Y.Assert.areEqual(
-                "[FAILEDTOBUILD]", this.td_status_img.get("alt"));
-            Y.Assert.areEqual(
-                "Failed to build", this.td_status_img.get("title"));
-            Y.Assert.areEqual(
-                "file:///@@/build-failed", this.td_status_img.get("src"));
-            Y.Assert.areEqual("16", this.td_status_img.get("width"));
-            Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
+            this.assert_node_matches({
+                "attrs": {"class": "build_status FAILEDTOBUILD"},
+                "text": "Failed to build",
+                "children": [
+                    {
+                        "tag": "img",
+                        "attrs": {
+                            "alt": "[FAILEDTOBUILD]",
+                            "title": "Failed to build",
+                            "src": "/@@/build-failed",
+                            "width": "16"
+                        }
+                    },
+                    {
+                        "tag": "a",
+                        "attrs": {"href": original_a_href}
+                    }
+                ]
+            }, this.td_status);
         },
 
         test_update_build_status_dom_chrootwait: function() {
-            var original_a_href = this.td_status_a.get("href");
+            var original_a_href = this.td_status.one("a").getAttribute("href");
             var data = {
-                "1": {
-                    "status": "CHROOTWAIT",
-                    "build_log_url": null,
-                    "when_complete_estimate": false,
-                    "buildstate": "Chroot problem",
-                    "build_log_size": null,
-                    "when_complete": "1 minute ago"
+                "requests": {},
+                "builds": {
+                    "1": {
+                        "status": "CHROOTWAIT",
+                        "build_log_url": null,
+                        "when_complete_estimate": false,
+                        "buildstate": "Chroot problem",
+                        "build_log_size": null,
+                        "when_complete": "1 minute ago"
+                    }
                 }
             };
             module.domUpdate(this.table, data);
-            Y.Assert.areEqual(
-                "build_status CHROOTWAIT",
-                this.td_status.getAttribute("class"));
-            Y.Assert.areEqual(
-                "Chroot problem", this.td_status.get("text").trim());
-            Y.Assert.areEqual("[CHROOTWAIT]", this.td_status_img.get("alt"));
-            Y.Assert.areEqual(
-                "Chroot problem", this.td_status_img.get("title"));
-            Y.Assert.areEqual(
-                "file:///@@/build-chrootwait", this.td_status_img.get("src"));
-            Y.Assert.areEqual("14", this.td_status_img.get("width"));
-            Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
+            this.assert_node_matches({
+                "attrs": {"class": "build_status CHROOTWAIT"},
+                "text": "Chroot problem",
+                "children": [
+                    {
+                        "tag": "img",
+                        "attrs": {
+                            "alt": "[CHROOTWAIT]",
+                            "title": "Chroot problem",
+                            "src": "/@@/build-chrootwait",
+                            "width": "14"
+                        }
+                    },
+                    {
+                        "tag": "a",
+                        "attrs": {"href": original_a_href}
+                    }
+                ]
+            }, this.td_status);
         },
 
         test_update_build_date_dom: function() {
             var data = {
-                "1": {
-                    "status": "NEEDSBUILD",
-                    "build_log_url": "/+build/1/+files/build1.txt.gz",
-                    "when_complete_estimate": true,
-                    "buildstate": "Needs building",
-                    "build_log_size": 12345,
-                    "when_complete": "in 30 seconds"
+                "requests": {},
+                "builds": {
+                    "1": {
+                        "status": "NEEDSBUILD",
+                        "build_log_url": "/+build/1/+files/build1.txt.gz",
+                        "when_complete_estimate": true,
+                        "buildstate": "Needs building",
+                        "build_log_size": 12345,
+                        "when_complete": "in 30 seconds"
+                    }
                 }
             };
             module.domUpdate(this.table, data);
-            Y.Assert.areEqual(
-                "in 30 seconds (estimated) buildlog (12345 bytes)",
-                this.td_datebuilt.get("text").trim());
-            var td_datebuilt_a = this.td_datebuilt.one("a");
-            Y.Assert.isNotNull(td_datebuilt_a);
-            Y.Assert.areEqual("buildlog", td_datebuilt_a.get("text").trim());
-            Y.Assert.areEqual(
-                "sprite download", td_datebuilt_a.getAttribute("class"));
-            Y.Assert.areEqual(
-                "file://" + data["1"].build_log_url,
-                td_datebuilt_a.get("href"));
+            this.assert_node_matches({
+                "text": "in 30 seconds (estimated) buildlog (12345 bytes)",
+                "children": [{
+                    "tag": "a",
+                    "attrs": {
+                        "class": "sprite download",
+                        "href": data["builds"]["1"].build_log_url
+                    },
+                    "text": "buildlog"
+                }]
+            }, this.td_datebuilt);
         }
     }));
 

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/model/snap.py	2018-06-20 16:53:58 +0000
@@ -43,7 +43,10 @@
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
-from lp.app.browser.tales import DateTimeFormatterAPI
+from lp.app.browser.tales import (
+    ArchiveFormatterAPI,
+    DateTimeFormatterAPI,
+    )
 from lp.app.enums import PRIVATE_INFORMATION_TYPES
 from lp.app.errors import (
     IncompatibleArguments,
@@ -117,7 +120,9 @@
     LibraryFileContent,
     )
 from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint
+from lp.services.webapp.authorization import precache_permission_for_objects
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.model import WebhookTargetMixin
 from lp.snappy.adapters.buildarch import determine_architectures_to_build
@@ -175,9 +180,19 @@
     webservice-friendly view of an asynchronous build request.
     """
 
-    def __init__(self, snap, id):
-        self._job = getUtility(ISnapRequestBuildsJobSource).getBySnapAndID(
-            snap, id)
+    def __init__(self, snap, id, job=None):
+        if job is None:
+            job_source = getUtility(ISnapRequestBuildsJobSource)
+            job = job_source.getBySnapAndID(snap, id)
+        if snap != job.snap:
+            raise AssertionError(
+                "Mismatched SnapRequestBuildsJob: expected %r, got %r" %
+                (snap, job.snap))
+        if id != job.job_id:
+            raise AssertionError(
+                "Mismatched SnapRequestBuildsJob: expected %d, got %d" %
+                (id, job.job_id))
+        self._job = job
         self.snap = snap
         self.id = id
 
@@ -203,6 +218,11 @@
         """See `ISnapBuildRequest`."""
         return self._job.builds
 
+    @property
+    def archive(self):
+        """See `ISnapBuildRequest`."""
+        return self._job.archive
+
 
 @implementer(ISnap, IHasOwner)
 class Snap(Storm, WebhookTargetMixin):
@@ -626,6 +646,15 @@
         """See `ISnap`."""
         return SnapBuildRequest(self, job_id)
 
+    @property
+    def pending_build_requests(self):
+        """See `ISnap`."""
+        job_source = getUtility(ISnapRequestBuildsJobSource)
+        return [
+            SnapBuildRequest(self, job.job_id, job=job)
+            for job in job_source.findBySnap(
+                self, statuses=(JobStatus.WAITING, JobStatus.RUNNING))]
+
     def _getBuilds(self, filter_term, order_by):
         """The actual query to get the builds."""
         query_args = [
@@ -656,6 +685,10 @@
         order_by = Desc(SnapBuild.id)
         builds = self._getBuilds(filter_term, order_by)
 
+        # The user can obviously see this snap, and Snap._getBuilds ensures
+        # that they can see the relevant archive for each build as well.
+        precache_permission_for_objects(None, "launchpad.View", builds)
+
         # Prefetch data to keep DB query count constant
         lfas = load_related(LibraryFileAlias, builds, ["log_id"])
         load_related(LibraryFileContent, lfas, ["contentID"])
@@ -681,6 +714,67 @@
                 }
         return result
 
+    def getBuildSummaries(self, request_ids=None, build_ids=None):
+        """See `ISnap`."""
+        all_build_ids = []
+        result = {"requests": {}, "builds": {}}
+
+        if request_ids:
+            job_source = getUtility(ISnapRequestBuildsJobSource)
+            jobs = job_source.findBySnap(self, job_ids=request_ids)
+            requests = [
+                SnapBuildRequest(self, job.job_id, job=job) for job in jobs]
+            builds_by_request = job_source.findBuildsForJobs(jobs)
+            for builds in builds_by_request.values():
+                # It's safe to remove the proxy here, because the IDs will
+                # go through Snap._getBuilds which checks visibility.  This
+                # saves an Archive query per build in the security adapter.
+                all_build_ids.extend(
+                    [removeSecurityProxy(build).id for build in builds])
+        else:
+            requests = []
+
+        if build_ids:
+            all_build_ids.extend(build_ids)
+
+        all_build_summaries = self.getBuildSummariesForSnapBuildIds(
+            all_build_ids)
+
+        for request in requests:
+            build_summaries = []
+            for build in sorted(
+                    builds_by_request[request.id], key=attrgetter("id"),
+                    reverse=True):
+                if build.id in all_build_summaries:
+                    # Include enough information for
+                    # snap.update_build_statuses.js to populate new build
+                    # rows.
+                    build_summary = {
+                        "self_link": canonical_url(
+                            build, path_only_if_possible=True),
+                        "id": build.id,
+                        "distro_arch_series_link": canonical_url(
+                            build.distro_arch_series,
+                            path_only_if_possible=True),
+                        "architecture_tag": (
+                            build.distro_arch_series.architecturetag),
+                        "archive_link": ArchiveFormatterAPI(
+                            build.archive).link(None),
+                        }
+                    build_summary.update(all_build_summaries[build.id])
+                    build_summaries.append(build_summary)
+            result["requests"][request.id] = {
+                "status": request.status.name,
+                "error_message": request.error_message,
+                "builds": build_summaries,
+                }
+
+        for build_id in (build_ids or []):
+            if build_id in all_build_summaries:
+                result["builds"][build_id] = all_build_summaries[build_id]
+
+        return result
+
     @property
     def builds(self):
         """See `ISnap`."""

=== modified file 'lib/lp/snappy/model/snapjob.py'
--- lib/lp/snappy/model/snapjob.py	2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/model/snapjob.py	2018-06-20 16:53:58 +0000
@@ -12,6 +12,8 @@
     'SnapRequestBuildsJob',
     ]
 
+from itertools import chain
+
 from lazr.delegates import delegate_to
 from lazr.enum import (
     DBEnumeratedType,
@@ -29,11 +31,14 @@
     implementer,
     provider,
     )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import NotFoundError
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 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,
@@ -58,7 +63,10 @@
     ISnapRequestBuildsJobSource,
     )
 from lp.snappy.model.snapbuild import SnapBuild
-from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.archive import (
+    Archive,
+    get_enabled_archive_filter,
+    )
 
 
 class SnapJobType(DBEnumeratedType):
@@ -188,6 +196,29 @@
         return job
 
     @classmethod
+    def findBySnap(cls, snap, statuses=None, job_ids=None):
+        """See `ISnapRequestBuildsJobSource`."""
+        clauses = [
+            SnapJob.snap == snap,
+            SnapJob.job_type == cls.class_job_type,
+            ]
+        if statuses is not None:
+            clauses.extend([
+                SnapJob.job == Job.id,
+                Job._status.is_in(statuses),
+                ])
+        if job_ids is not None:
+            clauses.append(SnapJob.job_id.is_in(job_ids))
+        snap_jobs = IStore(SnapJob).find(SnapJob, *clauses)
+
+        def preload_jobs(rows):
+            load_related(Job, rows, ["job_id"])
+
+        return DecoratedResultSet(
+            snap_jobs, lambda snap_job: cls(snap_job),
+            pre_iter_hook=preload_jobs)
+
+    @classmethod
     def getBySnapAndID(cls, snap, job_id):
         """See `ISnapRequestBuildsJobSource`."""
         snap_job = IStore(SnapJob).find(
@@ -201,6 +232,31 @@
                 (job_id, snap))
         return cls(snap_job)
 
+    @classmethod
+    def findBuildsForJobs(cls, jobs, user=None):
+        """See `ISnapRequestBuildsJobSource`."""
+        build_ids = {
+            job.job_id: removeSecurityProxy(job).metadata.get("builds") or []
+            for job in jobs}
+        all_build_ids = set(chain.from_iterable(build_ids.values()))
+        if all_build_ids:
+            all_builds = {
+                build.id: build for build in IStore(SnapBuild).find(
+                    SnapBuild,
+                    SnapBuild.id.is_in(all_build_ids),
+                    SnapBuild.archive_id == Archive.id,
+                    Archive._enabled == True,
+                    get_enabled_archive_filter(
+                        user, include_public=True, include_subscribed=True))
+                }
+        else:
+            all_builds = {}
+        return {
+            job.job_id: [
+                all_builds[build_id] for build_id in build_ids[job.job_id]
+                if build_id in all_builds]
+            for job in jobs}
+
     def getOperationDescription(self):
         return "requesting builds of %s" % self.snap.name
 
@@ -246,11 +302,11 @@
     def builds(self):
         """See `ISnapRequestBuildsJob`."""
         build_ids = self.metadata.get("builds")
-        if build_ids is None:
-            return EmptyResultSet()
-        else:
+        if build_ids:
             return IStore(SnapBuild).find(
                 SnapBuild, SnapBuild.id.is_in(build_ids))
+        else:
+            return EmptyResultSet()
 
     @builds.setter
     def builds(self, builds):

=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	2018-04-21 10:15:26 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2018-06-20 16:53:58 +0000
@@ -141,6 +141,18 @@
         </tr>
       </thead>
       <tbody>
+        <tal:snap-build-requests repeat="request context/pending_build_requests">
+          <tr tal:attributes="id string:request-${request/id}">
+            <td colspan="3"
+                tal:attributes="class string:request_status ${request/status/name}">
+              <span tal:replace="structure request/image:icon"/>
+              <tal:title replace="request/status/title"/> build request
+            </td>
+            <td>
+              <tal:archive replace="structure request/archive/fmt:link"/>
+            </td>
+          </tr>
+        </tal:snap-build-requests>
         <tal:snap-builds repeat="build view/builds">
           <tr tal:attributes="id string:build-${build/id}">
             <td tal:attributes="class string:build_status ${build/status/name}">

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/tests/test_snap.py	2018-06-20 16:53:58 +0000
@@ -12,6 +12,7 @@
     timedelta,
     )
 import json
+from operator import attrgetter
 from textwrap import dedent
 from urlparse import urlsplit
 
@@ -381,7 +382,8 @@
             snap=Equals(snap),
             status=Equals(SnapBuildRequestStatus.PENDING),
             error_message=Is(None),
-            builds=AfterPreprocessing(set, MatchesSetwise())))
+            builds=AfterPreprocessing(set, MatchesSetwise()),
+            archive=Equals(snap.distro_series.main_archive)))
         [job] = getUtility(ISnapRequestBuildsJobSource).iterReady()
         self.assertThat(job, MatchesStructure(
             job_id=Equals(request.id),
@@ -656,6 +658,172 @@
                 1, 5)
         self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
 
+    def test_getBuildSummaries(self):
+        snap1 = self.factory.makeSnap()
+        snap2 = self.factory.makeSnap()
+        request11 = self.factory.makeSnapBuildRequest(snap=snap1)
+        request12 = self.factory.makeSnapBuildRequest(snap=snap1)
+        request2 = self.factory.makeSnapBuildRequest(snap=snap2)
+        self.factory.makeSnapBuildRequest()
+        build11 = self.factory.makeSnapBuild(snap=snap1)
+        build12 = self.factory.makeSnapBuild(snap=snap1)
+        build2 = self.factory.makeSnapBuild(snap=snap2)
+        self.factory.makeSnapBuild()
+        summary1 = snap1.getBuildSummaries(
+            request_ids=[request11.id, request12.id],
+            build_ids=[build11.id, build12.id])
+        summary2 = snap2.getBuildSummaries(
+            request_ids=[request2.id], build_ids=[build2.id])
+        request_summary_matcher = MatchesDict({
+            "status": Equals("PENDING"),
+            "error_message": Is(None),
+            "builds": Equals([]),
+            })
+        build_summary_matcher = MatchesDict({
+            "status": Equals("NEEDSBUILD"),
+            "buildstate": Equals("Needs building"),
+            "when_complete": Is(None),
+            "when_complete_estimate": Is(False),
+            "build_log_url": Is(None),
+            "build_log_size": Is(None),
+            })
+        self.assertThat(summary1, MatchesDict({
+            "requests": MatchesDict({
+                request11.id: request_summary_matcher,
+                request12.id: request_summary_matcher,
+                }),
+            "builds": MatchesDict({
+                build11.id: build_summary_matcher,
+                build12.id: build_summary_matcher,
+                }),
+            }))
+        self.assertThat(summary2, MatchesDict({
+            "requests": MatchesDict({request2.id: request_summary_matcher}),
+            "builds": MatchesDict({build2.id: build_summary_matcher}),
+            }))
+
+    def test_getBuildSummaries_empty_input(self):
+        snap = self.factory.makeSnap()
+        self.factory.makeSnapBuildRequest(snap=snap)
+        self.assertEqual(
+            {"requests": {}, "builds": {}},
+            snap.getBuildSummaries(request_ids=None, build_ids=None))
+        self.assertEqual(
+            {"requests": {}, "builds": {}},
+            snap.getBuildSummaries(request_ids=[], build_ids=[]))
+        self.assertEqual(
+            {"requests": {}, "builds": {}},
+            snap.getBuildSummaries(request_ids=(), build_ids=()))
+
+    def test_getBuildSummaries_not_matching_snap(self):
+        # getBuildSummaries does not return information for other snaps.
+        snap1 = self.factory.makeSnap()
+        snap2 = self.factory.makeSnap()
+        self.factory.makeSnapBuildRequest(snap=snap1)
+        self.factory.makeSnapBuild(snap=snap1)
+        request2 = self.factory.makeSnapBuildRequest(snap=snap2)
+        build2 = self.factory.makeSnapBuild(snap=snap2)
+        summary1 = snap1.getBuildSummaries(
+            request_ids=[request2.id], build_ids=[build2.id])
+        self.assertEqual({"requests": {}, "builds": {}}, summary1)
+
+    def test_getBuildSummaries_request_error_message_field(self):
+        # The error_message field for a build request should be None unless
+        # the build request failed.
+        snap = self.factory.makeSnap()
+        request = self.factory.makeSnapBuildRequest(snap=snap)
+        self.assertIsNone(request.error_message)
+        summary = snap.getBuildSummaries(request_ids=[request.id])
+        self.assertIsNone(summary["requests"][request.id]["error_message"])
+        job = removeSecurityProxy(request)._job
+        removeSecurityProxy(job).error_message = "Boom"
+        summary = snap.getBuildSummaries(request_ids=[request.id])
+        self.assertEqual(
+            "Boom", summary["requests"][request.id]["error_message"])
+
+    def test_getBuildSummaries_request_builds_field(self):
+        # The builds field should be an empty list unless the build request
+        # has completed and produced builds.
+        self.useFixture(GitHostingFixture(blob=dedent("""\
+            architectures:
+              - build-on: mips64el
+              - build-on: riscv64
+            """)))
+        job = self.makeRequestBuildsJob(["mips64el", "riscv64", "sh4"])
+        snap = job.snap
+        request = snap.getBuildRequest(job.job_id)
+        self.assertEqual([], list(request.builds))
+        summary = snap.getBuildSummaries(request_ids=[request.id])
+        self.assertEqual([], summary["requests"][request.id]["builds"])
+        with person_logged_in(job.requester):
+            with dbuser(config.ISnapRequestBuildsJobSource.dbuser):
+                JobRunner([job]).runAll()
+        summary = snap.getBuildSummaries(request_ids=[request.id])
+        expected_snap_url = "/~%s/+snap/%s" % (snap.owner.name, snap.name)
+        builds = sorted(request.builds, key=attrgetter("id"), reverse=True)
+        expected_builds = [
+            {
+                "self_link": expected_snap_url + "/+build/%d" % build.id,
+                "id": build.id,
+                "distro_arch_series_link": "/%s/%s/%s" % (
+                    snap.distro_series.distribution.name,
+                    snap.distro_series.name,
+                    build.distro_arch_series.architecturetag),
+                "architecture_tag": build.distro_arch_series.architecturetag,
+                "archive_link": (
+                    '<a href="/%s" class="sprite distribution">%s</a>' % (
+                        build.archive.distribution.name,
+                        build.archive.displayname)),
+                "status": "NEEDSBUILD",
+                "buildstate": "Needs building",
+                "when_complete": None,
+                "when_complete_estimate": False,
+                "build_log_url": None,
+                "build_log_size": None,
+            } for build in builds]
+        self.assertEqual(
+            expected_builds, summary["requests"][request.id]["builds"])
+
+    def test_getBuildSummaries_query_count(self):
+        # The DB query count remains constant regardless of the number of
+        # requests and the number of builds resulting from them.
+        self.useFixture(GitHostingFixture(blob=dedent("""\
+            architectures:
+              - build-on: mips64el
+              - build-on: riscv64
+            """)))
+        job = self.makeRequestBuildsJob(["mips64el", "riscv64", "sh4"])
+        snap = job.snap
+        request_ids = []
+        build_ids = []
+
+        def create_items():
+            request = self.factory.makeSnapBuildRequest(
+                snap=snap, archive=self.factory.makeArchive())
+            request_ids.append(request.id)
+            job = removeSecurityProxy(request)._job
+            with person_logged_in(snap.owner.teamowner):
+                # Using the normal job runner interferes with SQL statement
+                # recording, so we run the job by hand.
+                job.start()
+                job.run()
+                job.complete()
+            # XXX cjwatson 2018-06-20: Queued builds with
+            # BuildQueueStatus.WAITING incur extra queries per build due to
+            # estimating start times.  For the moment, we dodge this by
+            # starting the builds.
+            for build in job.builds:
+                build.buildqueue_record.markAsBuilding(
+                    self.factory.makeBuilder())
+            build_ids.append(self.factory.makeSnapBuild(
+                snap=snap, archive=self.factory.makeArchive()).id)
+
+        recorder1, recorder2 = record_two_runs(
+            lambda: snap.getBuildSummaries(
+                request_ids=request_ids, build_ids=build_ids),
+            create_items, 1, 5)
+        self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
 
 class TestSnapDeleteWithBuilds(TestCaseWithFactory):
 

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2018-04-21 10:01:22 +0000
+++ lib/lp/testing/factory.py	2018-06-20 16:53:58 +0000
@@ -4710,6 +4710,19 @@
         IStore(snap).flush()
         return snap
 
+    def makeSnapBuildRequest(self, snap=None, requester=None, archive=None,
+                             pocket=PackagePublishingPocket.UPDATES,
+                             channels=None):
+        """Make a new SnapBuildRequest."""
+        if snap is None:
+            snap = self.makeSnap()
+        if requester is None:
+            requester = snap.owner.teamowner
+        if archive is None:
+            archive = snap.distro_series.main_archive
+        return snap.requestBuilds(
+            requester, archive, pocket, channels=channels)
+
     def makeSnapBuild(self, requester=None, registrant=None, snap=None,
                       archive=None, distroarchseries=None, pocket=None,
                       channels=None, date_created=DEFAULT,


Follow ups