← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-failed-build-requests into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-failed-build-requests into lp:launchpad.

Commit message:
Show recent failed build requests on Snap:+index.

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-failed-build-requests/+merge/355773

This is a bit fiddly since we need to interleave various items for the "Latest builds" table, but fortunately the total number of non-pending items we display there is bounded so we don't need to be too clever.

The new export of Snap.failed_build_requests is needed to implement the design proposals in https://github.com/canonical-websites/build.snapcraft.io/issues/556.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-failed-build-requests into lp:launchpad.
=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
--- lib/lp/code/browser/tests/test_branchmergeproposal.py	2018-06-21 17:26:43 +0000
+++ lib/lp/code/browser/tests/test_branchmergeproposal.py	2018-09-27 13:59:29 +0000
@@ -93,6 +93,7 @@
 from lp.services.librarian.interfaces.client import LibrarianServerError
 from lp.services.messages.model.message import MessageSet
 from lp.services.timeout import TimeoutError
+from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import canonical_url
 from lp.services.webapp.interfaces import BrowserNotificationLevel
 from lp.services.webapp.servers import LaunchpadTestRequest
@@ -1515,7 +1516,6 @@
         author = self.factory.makePerson()
         with person_logged_in(author):
             author_email = author.preferredemail.email
-        epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
         review_date = self.factory.getUniqueDate()
         commit_date = self.factory.getUniqueDate()
         bmp = self.factory.makeBranchMergeProposalForGit(
@@ -1527,7 +1527,7 @@
                 'author': {
                     'name': author.display_name,
                     'email': author_email,
-                    'time': int((commit_date - epoch).total_seconds()),
+                    'time': int(seconds_since_epoch(commit_date)),
                     },
                 }
             ]))
@@ -1614,7 +1614,6 @@
         # SHA-1 and can ask the repository for its unmerged commits.
         bmp = self.factory.makeBranchMergeProposalForGit()
         sha1 = unicode(hashlib.sha1(b'0').hexdigest())
-        epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
         commit_date = datetime(2015, 1, 1, tzinfo=pytz.UTC)
         self.useFixture(GitHostingFixture(log=[
             {
@@ -1623,7 +1622,7 @@
                 'author': {
                     'name': 'Example Person',
                     'email': 'person@xxxxxxxxxxx',
-                    'time': int((commit_date - epoch).total_seconds()),
+                    'time': int(seconds_since_epoch(commit_date)),
                     },
                 }
             ]))

=== modified file 'lib/lp/code/browser/tests/test_gitref.py'
--- lib/lp/code/browser/tests/test_gitref.py	2018-09-07 13:43:50 +0000
+++ lib/lp/code/browser/tests/test_gitref.py	2018-09-27 13:59:29 +0000
@@ -23,6 +23,7 @@
 from lp.code.tests.helpers import GitHostingFixture
 from lp.services.beautifulsoup import BeautifulSoup
 from lp.services.job.runner import JobRunner
+from lp.services.utils import seconds_since_epoch
 from lp.services.webapp.publisher import canonical_url
 from lp.testing import (
     admin_logged_in,
@@ -145,7 +146,6 @@
         authors = [self.factory.makePerson() for _ in range(5)]
         with admin_logged_in():
             author_emails = [author.preferredemail.email for author in authors]
-        epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
         dates = [
             datetime(2015, 1, day + 1, tzinfo=pytz.UTC) for day in range(5)]
         return [
@@ -155,12 +155,12 @@
                 "author": {
                     "name": authors[i].display_name,
                     "email": author_emails[i],
-                    "time": int((dates[i] - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(dates[i])),
                     },
                 "committer": {
                     "name": authors[i].display_name,
                     "email": author_emails[i],
-                    "time": int((dates[i] - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(dates[i])),
                     },
                 "parents": [unicode(hashlib.sha1(str(i - 1)).hexdigest())],
                 "tree": unicode(hashlib.sha1("").hexdigest()),

=== modified file 'lib/lp/code/model/gitref.py'
--- lib/lp/code/model/gitref.py	2018-08-23 12:34:24 +0000
+++ lib/lp/code/model/gitref.py	2018-09-27 13:59:29 +0000
@@ -9,7 +9,6 @@
     'GitRefRemote',
     ]
 
-from datetime import datetime
 from functools import partial
 import json
 import re
@@ -79,6 +78,7 @@
 from lp.services.features import getFeatureFlag
 from lp.services.memcache.interfaces import IMemcacheClient
 from lp.services.timeout import urlfetch
+from lp.services.utils import seconds_since_epoch
 from lp.services.webapp.interfaces import ILaunchBag
 
 
@@ -345,19 +345,18 @@
             else:
                 # Fall back to synthesising something reasonable based on
                 # information in our own database.
-                epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
                 log = [{
                     "sha1": self.commit_sha1,
                     "message": self.commit_message,
                     "author": None if self.author is None else {
                         "name": self.author.name_without_email,
                         "email": self.author.email,
-                        "time": (self.author_date - epoch).total_seconds(),
+                        "time": seconds_since_epoch(self.author_date),
                         },
                     "committer": None if self.committer is None else {
                         "name": self.committer.name_without_email,
                         "email": self.committer.email,
-                        "time": (self.committer_date - epoch).total_seconds(),
+                        "time": seconds_since_epoch(self.committer_date),
                         },
                     }]
         return log

=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
--- lib/lp/code/model/tests/test_gitjob.py	2017-10-04 01:29:35 +0000
+++ lib/lp/code/model/tests/test_gitjob.py	2018-09-27 13:59:29 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for `GitJob`s."""
@@ -44,6 +44,7 @@
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.runner import JobRunner
+from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import canonical_url
 from lp.testing import (
     TestCaseWithFactory,
@@ -97,7 +98,6 @@
 
     @staticmethod
     def makeFakeCommits(author, author_date_gen, paths):
-        epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
         dates = {path: next(author_date_gen) for path in paths}
         return [{
             "sha1": unicode(hashlib.sha1(path).hexdigest()),
@@ -105,12 +105,12 @@
             "author": {
                 "name": author.displayname,
                 "email": author.preferredemail.email,
-                "time": int((dates[path] - epoch).total_seconds()),
+                "time": int(seconds_since_epoch(dates[path])),
                 },
             "committer": {
                 "name": author.displayname,
                 "email": author.preferredemail.email,
-                "time": int((dates[path] - epoch).total_seconds()),
+                "time": int(seconds_since_epoch(dates[path])),
                 },
             "parents": [],
             "tree": unicode(hashlib.sha1("").hexdigest()),

=== modified file 'lib/lp/code/model/tests/test_gitref.py'
--- lib/lp/code/model/tests/test_gitref.py	2018-08-23 12:34:24 +0000
+++ lib/lp/code/model/tests/test_gitref.py	2018-09-27 13:59:29 +0000
@@ -42,6 +42,7 @@
 from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.memcache.interfaces import IMemcacheClient
+from lp.services.utils import seconds_since_epoch
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
     admin_logged_in,
@@ -134,7 +135,6 @@
         with admin_logged_in():
             self.author_emails = [
                 author.preferredemail.email for author in self.authors]
-        epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
         self.dates = [
             datetime(2015, 1, 1, 0, 0, 0, tzinfo=pytz.UTC),
             datetime(2015, 1, 2, 0, 0, 0, tzinfo=pytz.UTC),
@@ -148,12 +148,12 @@
                 "author": {
                     "name": self.authors[0].display_name,
                     "email": self.author_emails[0],
-                    "time": int((self.dates[1] - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(self.dates[1])),
                     },
                 "committer": {
                     "name": self.authors[1].display_name,
                     "email": self.author_emails[1],
-                    "time": int((self.dates[1] - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(self.dates[1])),
                     },
                 "parents": [self.sha1_root],
                 "tree": unicode(hashlib.sha1("").hexdigest()),
@@ -164,12 +164,12 @@
                 "author": {
                     "name": self.authors[1].display_name,
                     "email": self.author_emails[1],
-                    "time": int((self.dates[0] - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(self.dates[0])),
                     },
                 "committer": {
                     "name": self.authors[0].display_name,
                     "email": self.author_emails[0],
-                    "time": int((self.dates[0] - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(self.dates[0])),
                     },
                 "parents": [],
                 "tree": unicode(hashlib.sha1("").hexdigest()),

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2018-08-31 14:25:40 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2018-09-27 13:59:29 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for Git repositories."""
@@ -124,6 +124,7 @@
 from lp.services.job.runner import JobRunner
 from lp.services.mail import stub
 from lp.services.propertycache import clear_property_cache
+from lp.services.utils import seconds_since_epoch
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
@@ -1283,7 +1284,6 @@
         author = self.factory.makePerson()
         with person_logged_in(author):
             author_email = author.preferredemail.email
-        epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
         author_date = datetime(2015, 1, 1, tzinfo=pytz.UTC)
         committer_date = datetime(2015, 1, 2, tzinfo=pytz.UTC)
         hosting_fixture = self.useFixture(GitHostingFixture(commits=[
@@ -1293,12 +1293,12 @@
                 "author": {
                     "name": author.displayname,
                     "email": author_email,
-                    "time": int((author_date - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(author_date)),
                     },
                 "committer": {
                     "name": "New Person",
                     "email": "new-person@xxxxxxxxxxx",
-                    "time": int((committer_date - epoch).total_seconds()),
+                    "time": int(seconds_since_epoch(committer_date)),
                     },
                 "parents": [],
                 "tree": unicode(hashlib.sha1("").hexdigest()),

=== modified file 'lib/lp/code/stories/branches/xx-code-review-comments.txt'
--- lib/lp/code/stories/branches/xx-code-review-comments.txt	2018-05-13 10:35:52 +0000
+++ lib/lp/code/stories/branches/xx-code-review-comments.txt	2018-09-27 13:59:29 +0000
@@ -192,11 +192,11 @@
 log entries first.
 
     >>> from lp.code.tests.helpers import GitHostingFixture
+    >>> from lp.services.utils import seconds_since_epoch
 
     >>> login('admin@xxxxxxxxxxxxx')
     >>> bmp = factory.makeBranchMergeProposalForGit()
     >>> bmp.requestReview(review_date)
-    >>> epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
     >>> commit_date = review_date + timedelta(days=1)
     >>> hosting_fixture = GitHostingFixture()
     >>> for i in range(2):
@@ -206,7 +206,7 @@
     ...         u'author': {
     ...             u'name': bmp.registrant.display_name,
     ...             u'email': bmp.registrant.preferredemail.email,
-    ...             u'time': int((commit_date - epoch).total_seconds()),
+    ...             u'time': int(seconds_since_epoch(commit_date)),
     ...             },
     ...         })
     ...     hosting_fixture.getLog.result.insert(0, {
@@ -215,7 +215,7 @@
     ...         u'author': {
     ...             u'name': bmp.registrant.display_name,
     ...             u'email': bmp.registrant.preferredemail.email,
-    ...             u'time': int((commit_date - epoch).total_seconds()),
+    ...             u'time': int(seconds_since_epoch(commit_date)),
     ...             },
     ...         })
     ...     commit_date += timedelta(days=1)

=== modified file 'lib/lp/services/tests/test_utils.py'
--- lib/lp/services/tests/test_utils.py	2018-04-17 09:41:46 +0000
+++ lib/lp/services/tests/test_utils.py	2018-09-27 13:59:29 +0000
@@ -37,6 +37,7 @@
     run_capturing_output,
     sanitise_urls,
     save_bz2_pickle,
+    seconds_since_epoch,
     traceback_info,
     utc_now,
     )
@@ -380,6 +381,18 @@
         self.assertThat(now, LessThanOrEqual(new_now))
 
 
+class TestSecondsSinceEpoch(TestCase):
+    """Tests for `seconds_since_epoch`."""
+
+    def test_epoch(self):
+        epoch = datetime.fromtimestamp(0, tz=UTC)
+        self.assertEqual(0, seconds_since_epoch(epoch))
+
+    def test_start_of_2018(self):
+        dt = datetime(2018, 1, 1, tzinfo=UTC)
+        self.assertEqual(1514764800, seconds_since_epoch(dt))
+
+
 class TestBZ2Pickle(TestCase):
     """Tests for `save_bz2_pickle` and `load_bz2_pickle`."""
 

=== modified file 'lib/lp/services/utils.py'
--- lib/lp/services/utils.py	2018-04-17 09:41:46 +0000
+++ lib/lp/services/utils.py	2018-09-27 13:59:29 +0000
@@ -25,6 +25,7 @@
     'run_capturing_output',
     'sanitise_urls',
     'save_bz2_pickle',
+    'seconds_since_epoch',
     'text_delta',
     'traceback_info',
     'utc_now',
@@ -302,6 +303,14 @@
     return datetime.now(tz=pytz.UTC)
 
 
+_epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
+
+
+def seconds_since_epoch(dt):
+    """Express a `datetime` as the number of seconds since the Unix epoch."""
+    return (dt - _epoch).total_seconds()
+
+
 # This is a regular expression that matches email address embedded in
 # text. It is not RFC 2821 compliant, nor does it need to be. This
 # expression strives to identify probable email addresses so that they

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2018-09-13 15:21:05 +0000
+++ lib/lp/snappy/browser/snap.py	2018-09-27 13:59:29 +0000
@@ -57,7 +57,9 @@
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.features import getFeatureFlag
 from lp.services.helpers import english_list
+from lp.services.propertycache import cachedproperty
 from lp.services.scripts import log
+from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import (
     canonical_url,
     ContextMenu,
@@ -178,9 +180,9 @@
 class SnapView(LaunchpadView):
     """Default view of a Snap."""
 
-    @property
-    def builds(self):
-        return builds_for_snap(self.context)
+    @cachedproperty
+    def builds_and_requests(self):
+        return builds_and_requests_for_snap(self.context)
 
     @property
     def person_picker(self):
@@ -209,23 +211,47 @@
         return ', '.join(self.context.store_channels)
 
 
-def builds_for_snap(snap):
-    """A list of interesting builds.
+def builds_and_requests_for_snap(snap):
+    """A list of interesting builds and build requests.
 
-    All pending builds are shown, as well as 1-10 recent builds.  Recent
-    builds are ordered by date finished (if completed) or date_started (if
-    date finished is not set due to an error building or other circumstance
-    which resulted in the build not being completed).  This allows started
-    but unfinished builds to show up in the view but be discarded as more
-    recent builds become available.
+    All pending builds and pending build requests are shown, as well as up
+    to 10 recent builds and recent failed build requests.  Pending items are
+    ordered by the date they were created; recent items are ordered by the
+    date they finished (if available) or the date they started (if the date
+    they finished is not set due to an error).  This allows started but
+    unfinished builds to show up in the view but be discarded as more recent
+    builds become available.
 
     Builds that the user does not have permission to see are excluded (by
     the model code).
     """
-    builds = list(snap.pending_builds)
-    if len(builds) < 10:
-        builds.extend(snap.completed_builds[:10 - len(builds)])
-    return builds
+    # We need to interleave items of different types, so SQL can't do all
+    # the sorting for us.
+    def make_sort_key(*date_attrs):
+        def _sort_key(item):
+            for date_attr in date_attrs:
+                if getattr(item, date_attr, None) is not None:
+                    return -seconds_since_epoch(getattr(item, date_attr))
+            return 0
+
+        return _sort_key
+
+    items = sorted(
+        list(snap.pending_builds) + list(snap.pending_build_requests),
+        key=make_sort_key("date_created", "date_requested"))
+    if len(items) < 10:
+        # We need to interleave two unbounded result sets, but we only need
+        # enough items from them to make the total count up to 10.  It's
+        # simplest to just fetch the upper bound from each set and do our
+        # own sorting.
+        recent_items = sorted(
+            list(snap.completed_builds[:10 - len(items)]) +
+            list(snap.failed_build_requests[:10 - len(items)]),
+            key=make_sort_key(
+                "date_finished", "date_started",
+                "date_created", "date_requested"))
+        items.extend(recent_items[:10 - len(items)])
+    return items
 
 
 def new_builds_notification_text(builds, already_pending=None):

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2018-09-13 15:21:05 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2018-09-27 13:59:29 +0000
@@ -29,6 +29,7 @@
     AfterPreprocessing,
     Equals,
     Is,
+    MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
     )
@@ -56,6 +57,8 @@
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.propertycache import get_property_cache
 from lp.services.webapp import canonical_url
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.snappy.browser.snap import (
@@ -1344,7 +1347,7 @@
             "This snap package has not been built yet.",
             self.getMainText(snap))
 
-    def test_index_pending(self):
+    def test_index_pending_build(self):
         # A pending build is listed as such.
         build = self.makeBuild()
         build.queueBuild()
@@ -1355,6 +1358,38 @@
             Primary Archive for Ubuntu Linux
             """, self.getMainText(build.snap))
 
+    def test_index_pending_build_request(self):
+        # A pending build request is listed as such.
+        snap = self.makeSnap()
+        with person_logged_in(snap.owner):
+            snap.requestBuilds(
+                snap.owner, snap.distro_series.main_archive,
+                PackagePublishingPocket.UPDATES)
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture Archive
+            Pending build request
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(snap))
+
+    def test_index_failed_build_request(self):
+        # A failed build request is listed as such, with its error message.
+        snap = self.makeSnap()
+        with person_logged_in(snap.owner):
+            request = snap.requestBuilds(
+                snap.owner, snap.distro_series.main_archive,
+                PackagePublishingPocket.UPDATES)
+        job = removeSecurityProxy(removeSecurityProxy(request)._job)
+        job.job._status = JobStatus.FAILED
+        job.job.date_finished = datetime.now(pytz.UTC) - timedelta(hours=1)
+        job.error_message = "Boom"
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture Archive
+            Failed build request 1 hour ago \(Boom\)
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(snap))
+
     def test_index_store_upload(self):
         # If the snap package is to be automatically uploaded to the store,
         # the index page shows details of this.
@@ -1382,8 +1417,8 @@
         build.updateStatus(
             status, date_finished=build.date_started + timedelta(minutes=30))
 
-    def test_builds(self):
-        # SnapView.builds produces reasonable results.
+    def test_builds_and_requests(self):
+        # SnapView.builds_and_requests produces reasonable results.
         snap = self.makeSnap()
         # Create oldest builds first so that they sort properly by id.
         date_gen = time_counter(
@@ -1392,16 +1427,67 @@
             self.makeBuild(snap=snap, date_created=next(date_gen))
             for i in range(11)]
         view = SnapView(snap, None)
-        self.assertEqual(list(reversed(builds)), view.builds)
+        self.assertEqual(list(reversed(builds)), view.builds_and_requests)
         self.setStatus(builds[10], BuildStatus.FULLYBUILT)
         self.setStatus(builds[9], BuildStatus.FAILEDTOBUILD)
+        del get_property_cache(view).builds_and_requests
         # When there are >= 9 pending builds, only the most recent of any
         # completed builds is returned.
         self.assertEqual(
-            list(reversed(builds[:9])) + [builds[10]], view.builds)
+            list(reversed(builds[:9])) + [builds[10]],
+            view.builds_and_requests)
         for build in builds[:9]:
             self.setStatus(build, BuildStatus.FULLYBUILT)
-        self.assertEqual(list(reversed(builds[1:])), view.builds)
+        del get_property_cache(view).builds_and_requests
+        self.assertEqual(list(reversed(builds[1:])), view.builds_and_requests)
+
+    def test_builds_and_requests_shows_build_requests(self):
+        # SnapView.builds_and_requests interleaves build requests with
+        # builds.
+        snap = self.makeSnap()
+        date_gen = time_counter(
+            datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
+        builds = [
+            self.makeBuild(snap=snap, date_created=next(date_gen))
+            for i in range(3)]
+        self.setStatus(builds[2], BuildStatus.FULLYBUILT)
+        with person_logged_in(snap.owner):
+            request = snap.requestBuilds(
+                snap.owner, snap.distro_series.main_archive,
+                PackagePublishingPocket.UPDATES)
+        job = removeSecurityProxy(removeSecurityProxy(request)._job)
+        job.job.date_created = next(date_gen)
+        view = SnapView(snap, None)
+        # The pending build request is interleaved in date order with
+        # pending builds, and these are followed by completed builds.
+        self.assertThat(view.builds_and_requests, MatchesListwise([
+            MatchesStructure.byEquality(id=request.id),
+            Equals(builds[1]),
+            Equals(builds[0]),
+            Equals(builds[2]),
+            ]))
+        transaction.commit()
+        builds.append(self.makeBuild(snap=snap))
+        del get_property_cache(view).builds_and_requests
+        self.assertThat(view.builds_and_requests, MatchesListwise([
+            Equals(builds[3]),
+            MatchesStructure.byEquality(id=request.id),
+            Equals(builds[1]),
+            Equals(builds[0]),
+            Equals(builds[2]),
+            ]))
+        # If we pretend that the job failed, it is still listed, but after
+        # any pending builds.
+        job.job._status = JobStatus.FAILED
+        job.job.date_finished = job.date_created + timedelta(minutes=30)
+        del get_property_cache(view).builds_and_requests
+        self.assertThat(view.builds_and_requests, MatchesListwise([
+            Equals(builds[3]),
+            Equals(builds[1]),
+            Equals(builds[0]),
+            MatchesStructure.byEquality(id=request.id),
+            Equals(builds[2]),
+            ]))
 
     def test_store_channels_empty(self):
         snap = self.factory.makeSnap()

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2018-09-13 15:21:05 +0000
+++ lib/lp/snappy/interfaces/snap.py	2018-09-27 13:59:29 +0000
@@ -459,6 +459,11 @@
         value_type=Reference(ISnapBuildRequest),
         required=True, readonly=True)))
 
+    failed_build_requests = exported(doNotSnapshot(CollectionField(
+        title=_("Failed build requests for this snap package."),
+        value_type=Reference(ISnapBuildRequest),
+        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.

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2018-09-13 15:21:05 +0000
+++ lib/lp/snappy/model/snap.py	2018-09-27 13:59:29 +0000
@@ -676,6 +676,14 @@
             self, statuses=(JobStatus.WAITING, JobStatus.RUNNING))
         return [SnapBuildRequest.fromJob(job) for job in jobs]
 
+    @property
+    def failed_build_requests(self):
+        """See `ISnap`."""
+        job_source = getUtility(ISnapRequestBuildsJobSource)
+        # The returned jobs are ordered by descending ID.
+        jobs = job_source.findBySnap(self, statuses=(JobStatus.FAILED,))
+        return [SnapBuildRequest.fromJob(job) for job in jobs]
+
     def _getBuilds(self, filter_term, order_by):
         """The actual query to get the builds."""
         query_args = [

=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	2018-09-13 15:21:05 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2018-09-27 13:59:29 +0000
@@ -156,51 +156,62 @@
         </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}">
-              <span tal:replace="structure build/image:icon"/>
-              <a tal:content="build/status/title"
-                 tal:attributes="href build/fmt:url"/>
-            </td>
-            <td class="datebuilt">
-              <tal:date replace="build/date/fmt:displaydate"/>
-              <tal:estimate condition="build/estimate">
-                (estimated)
-              </tal:estimate>
+        <tal:snap-builds-and-requests repeat="item view/builds_and_requests">
+          <tal:snap-build-request condition="item/date_requested|nothing">
+            <tr tal:define="request item"
+                tal:attributes="id string:request-${request/id}">
+              <td 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:date condition="request/date_finished"
+                          replace="request/date_finished/fmt:displaydate"/>
+                <tal:error-message condition="request/error_message">
+                  (<span tal:replace="request/error_message"/>)
+                </tal:error-message>
+              </td>
+              <td/>
+              <td>
+                <tal:archive replace="structure request/archive/fmt:link"/>
+              </td>
+            </tr>
+          </tal:snap-build-request>
+          <tal:snap-build condition="not: item/date_requested|nothing">
+            <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"/>
+              </td>
+              <td class="datebuilt">
+                <tal:date replace="build/date/fmt:displaydate"/>
+                <tal:estimate condition="build/estimate">
+                  (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>
-            <td>
-              <a class="sprite distribution"
-                 tal:define="archseries build/distro_arch_series"
-                 tal:attributes="href archseries/fmt:url"
-                 tal:content="archseries/architecturetag"/>
-            </td>
-            <td>
-              <tal:archive replace="structure build/archive/fmt:link"/>
-            </td>
-          </tr>
-        </tal:snap-builds>
+                <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>
+              <td>
+                <a class="sprite distribution"
+                   tal:define="archseries build/distro_arch_series"
+                   tal:attributes="href archseries/fmt:url"
+                   tal:content="archseries/architecturetag"/>
+              </td>
+              <td>
+                <tal:archive replace="structure build/archive/fmt:link"/>
+              </td>
+            </tr>
+          </tal:snap-build>
+        </tal:snap-builds-and-requests>
       </tbody>
     </table>
-    <p tal:condition="not: view/builds">
+    <p tal:condition="not: view/builds_and_requests">
       This snap package has not been built yet.
     </p>
     <div tal:define="link context/menu:context/request_builds"

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2018-09-13 15:21:05 +0000
+++ lib/lp/snappy/tests/test_snap.py	2018-09-27 13:59:29 +0000
@@ -177,7 +177,7 @@
         self.assertThat(
             self.factory.makeSnap(),
             DoesNotSnapshot(
-                ["pending_build_requests",
+                ["pending_build_requests", "failed_build_requests",
                  "builds", "completed_builds", "pending_builds"],
                 ISnapView))
 


Follow ups