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

Proposed by Colin Watson on 2018-09-27
Status: Merged
Merged at revision: 18789
Proposed branch: lp:~cjwatson/launchpad/snap-failed-build-requests
Merge into: lp:launchpad
Diff against target: 745 lines (+250/-92)
15 files modified
lib/lp/code/browser/tests/test_branchmergeproposal.py (+3/-4)
lib/lp/code/browser/tests/test_gitref.py (+3/-3)
lib/lp/code/model/gitref.py (+3/-4)
lib/lp/code/model/tests/test_gitjob.py (+4/-4)
lib/lp/code/model/tests/test_gitref.py (+5/-5)
lib/lp/code/model/tests/test_gitrepository.py (+4/-4)
lib/lp/code/stories/branches/xx-code-review-comments.txt (+3/-3)
lib/lp/services/tests/test_utils.py (+13/-0)
lib/lp/services/utils.py (+9/-0)
lib/lp/snappy/browser/snap.py (+41/-15)
lib/lp/snappy/browser/tests/test_snap.py (+92/-6)
lib/lp/snappy/interfaces/snap.py (+5/-0)
lib/lp/snappy/model/snap.py (+11/-1)
lib/lp/snappy/templates/snap-index.pt (+53/-42)
lib/lp/snappy/tests/test_snap.py (+1/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-failed-build-requests
Reviewer Review Type Date Requested Status
William Grant code 2018-09-27 Approve on 2018-10-02
Review via email: mp+355773@code.launchpad.net

Commit message

Show recent failed build requests on Snap:+index.

Description of the change

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.

To post a comment you must log in.
William Grant (wgrant) :
review: Approve (code)
18786. By Colin Watson on 2018-10-03

Convert Snap.{pending,failed}_build_requests to DecoratedResultSet.

Snap.failed_build_requests, in particular, may be large and require
batching.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
2--- lib/lp/code/browser/tests/test_branchmergeproposal.py 2018-06-21 17:26:43 +0000
3+++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2018-10-03 00:52:37 +0000
4@@ -93,6 +93,7 @@
5 from lp.services.librarian.interfaces.client import LibrarianServerError
6 from lp.services.messages.model.message import MessageSet
7 from lp.services.timeout import TimeoutError
8+from lp.services.utils import seconds_since_epoch
9 from lp.services.webapp import canonical_url
10 from lp.services.webapp.interfaces import BrowserNotificationLevel
11 from lp.services.webapp.servers import LaunchpadTestRequest
12@@ -1515,7 +1516,6 @@
13 author = self.factory.makePerson()
14 with person_logged_in(author):
15 author_email = author.preferredemail.email
16- epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
17 review_date = self.factory.getUniqueDate()
18 commit_date = self.factory.getUniqueDate()
19 bmp = self.factory.makeBranchMergeProposalForGit(
20@@ -1527,7 +1527,7 @@
21 'author': {
22 'name': author.display_name,
23 'email': author_email,
24- 'time': int((commit_date - epoch).total_seconds()),
25+ 'time': int(seconds_since_epoch(commit_date)),
26 },
27 }
28 ]))
29@@ -1614,7 +1614,6 @@
30 # SHA-1 and can ask the repository for its unmerged commits.
31 bmp = self.factory.makeBranchMergeProposalForGit()
32 sha1 = unicode(hashlib.sha1(b'0').hexdigest())
33- epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
34 commit_date = datetime(2015, 1, 1, tzinfo=pytz.UTC)
35 self.useFixture(GitHostingFixture(log=[
36 {
37@@ -1623,7 +1622,7 @@
38 'author': {
39 'name': 'Example Person',
40 'email': 'person@example.org',
41- 'time': int((commit_date - epoch).total_seconds()),
42+ 'time': int(seconds_since_epoch(commit_date)),
43 },
44 }
45 ]))
46
47=== modified file 'lib/lp/code/browser/tests/test_gitref.py'
48--- lib/lp/code/browser/tests/test_gitref.py 2018-09-07 13:43:50 +0000
49+++ lib/lp/code/browser/tests/test_gitref.py 2018-10-03 00:52:37 +0000
50@@ -23,6 +23,7 @@
51 from lp.code.tests.helpers import GitHostingFixture
52 from lp.services.beautifulsoup import BeautifulSoup
53 from lp.services.job.runner import JobRunner
54+from lp.services.utils import seconds_since_epoch
55 from lp.services.webapp.publisher import canonical_url
56 from lp.testing import (
57 admin_logged_in,
58@@ -145,7 +146,6 @@
59 authors = [self.factory.makePerson() for _ in range(5)]
60 with admin_logged_in():
61 author_emails = [author.preferredemail.email for author in authors]
62- epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
63 dates = [
64 datetime(2015, 1, day + 1, tzinfo=pytz.UTC) for day in range(5)]
65 return [
66@@ -155,12 +155,12 @@
67 "author": {
68 "name": authors[i].display_name,
69 "email": author_emails[i],
70- "time": int((dates[i] - epoch).total_seconds()),
71+ "time": int(seconds_since_epoch(dates[i])),
72 },
73 "committer": {
74 "name": authors[i].display_name,
75 "email": author_emails[i],
76- "time": int((dates[i] - epoch).total_seconds()),
77+ "time": int(seconds_since_epoch(dates[i])),
78 },
79 "parents": [unicode(hashlib.sha1(str(i - 1)).hexdigest())],
80 "tree": unicode(hashlib.sha1("").hexdigest()),
81
82=== modified file 'lib/lp/code/model/gitref.py'
83--- lib/lp/code/model/gitref.py 2018-08-23 12:34:24 +0000
84+++ lib/lp/code/model/gitref.py 2018-10-03 00:52:37 +0000
85@@ -9,7 +9,6 @@
86 'GitRefRemote',
87 ]
88
89-from datetime import datetime
90 from functools import partial
91 import json
92 import re
93@@ -79,6 +78,7 @@
94 from lp.services.features import getFeatureFlag
95 from lp.services.memcache.interfaces import IMemcacheClient
96 from lp.services.timeout import urlfetch
97+from lp.services.utils import seconds_since_epoch
98 from lp.services.webapp.interfaces import ILaunchBag
99
100
101@@ -345,19 +345,18 @@
102 else:
103 # Fall back to synthesising something reasonable based on
104 # information in our own database.
105- epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
106 log = [{
107 "sha1": self.commit_sha1,
108 "message": self.commit_message,
109 "author": None if self.author is None else {
110 "name": self.author.name_without_email,
111 "email": self.author.email,
112- "time": (self.author_date - epoch).total_seconds(),
113+ "time": seconds_since_epoch(self.author_date),
114 },
115 "committer": None if self.committer is None else {
116 "name": self.committer.name_without_email,
117 "email": self.committer.email,
118- "time": (self.committer_date - epoch).total_seconds(),
119+ "time": seconds_since_epoch(self.committer_date),
120 },
121 }]
122 return log
123
124=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
125--- lib/lp/code/model/tests/test_gitjob.py 2017-10-04 01:29:35 +0000
126+++ lib/lp/code/model/tests/test_gitjob.py 2018-10-03 00:52:37 +0000
127@@ -1,4 +1,4 @@
128-# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
129+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
130 # GNU Affero General Public License version 3 (see the file LICENSE).
131
132 """Tests for `GitJob`s."""
133@@ -44,6 +44,7 @@
134 from lp.services.database.constants import UTC_NOW
135 from lp.services.features.testing import FeatureFixture
136 from lp.services.job.runner import JobRunner
137+from lp.services.utils import seconds_since_epoch
138 from lp.services.webapp import canonical_url
139 from lp.testing import (
140 TestCaseWithFactory,
141@@ -97,7 +98,6 @@
142
143 @staticmethod
144 def makeFakeCommits(author, author_date_gen, paths):
145- epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
146 dates = {path: next(author_date_gen) for path in paths}
147 return [{
148 "sha1": unicode(hashlib.sha1(path).hexdigest()),
149@@ -105,12 +105,12 @@
150 "author": {
151 "name": author.displayname,
152 "email": author.preferredemail.email,
153- "time": int((dates[path] - epoch).total_seconds()),
154+ "time": int(seconds_since_epoch(dates[path])),
155 },
156 "committer": {
157 "name": author.displayname,
158 "email": author.preferredemail.email,
159- "time": int((dates[path] - epoch).total_seconds()),
160+ "time": int(seconds_since_epoch(dates[path])),
161 },
162 "parents": [],
163 "tree": unicode(hashlib.sha1("").hexdigest()),
164
165=== modified file 'lib/lp/code/model/tests/test_gitref.py'
166--- lib/lp/code/model/tests/test_gitref.py 2018-08-23 12:34:24 +0000
167+++ lib/lp/code/model/tests/test_gitref.py 2018-10-03 00:52:37 +0000
168@@ -42,6 +42,7 @@
169 from lp.services.config import config
170 from lp.services.features.testing import FeatureFixture
171 from lp.services.memcache.interfaces import IMemcacheClient
172+from lp.services.utils import seconds_since_epoch
173 from lp.services.webapp.interfaces import OAuthPermission
174 from lp.testing import (
175 admin_logged_in,
176@@ -134,7 +135,6 @@
177 with admin_logged_in():
178 self.author_emails = [
179 author.preferredemail.email for author in self.authors]
180- epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
181 self.dates = [
182 datetime(2015, 1, 1, 0, 0, 0, tzinfo=pytz.UTC),
183 datetime(2015, 1, 2, 0, 0, 0, tzinfo=pytz.UTC),
184@@ -148,12 +148,12 @@
185 "author": {
186 "name": self.authors[0].display_name,
187 "email": self.author_emails[0],
188- "time": int((self.dates[1] - epoch).total_seconds()),
189+ "time": int(seconds_since_epoch(self.dates[1])),
190 },
191 "committer": {
192 "name": self.authors[1].display_name,
193 "email": self.author_emails[1],
194- "time": int((self.dates[1] - epoch).total_seconds()),
195+ "time": int(seconds_since_epoch(self.dates[1])),
196 },
197 "parents": [self.sha1_root],
198 "tree": unicode(hashlib.sha1("").hexdigest()),
199@@ -164,12 +164,12 @@
200 "author": {
201 "name": self.authors[1].display_name,
202 "email": self.author_emails[1],
203- "time": int((self.dates[0] - epoch).total_seconds()),
204+ "time": int(seconds_since_epoch(self.dates[0])),
205 },
206 "committer": {
207 "name": self.authors[0].display_name,
208 "email": self.author_emails[0],
209- "time": int((self.dates[0] - epoch).total_seconds()),
210+ "time": int(seconds_since_epoch(self.dates[0])),
211 },
212 "parents": [],
213 "tree": unicode(hashlib.sha1("").hexdigest()),
214
215=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
216--- lib/lp/code/model/tests/test_gitrepository.py 2018-08-31 14:25:40 +0000
217+++ lib/lp/code/model/tests/test_gitrepository.py 2018-10-03 00:52:37 +0000
218@@ -1,4 +1,4 @@
219-# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
220+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
221 # GNU Affero General Public License version 3 (see the file LICENSE).
222
223 """Tests for Git repositories."""
224@@ -124,6 +124,7 @@
225 from lp.services.job.runner import JobRunner
226 from lp.services.mail import stub
227 from lp.services.propertycache import clear_property_cache
228+from lp.services.utils import seconds_since_epoch
229 from lp.services.webapp.authorization import check_permission
230 from lp.services.webapp.interfaces import OAuthPermission
231 from lp.testing import (
232@@ -1283,7 +1284,6 @@
233 author = self.factory.makePerson()
234 with person_logged_in(author):
235 author_email = author.preferredemail.email
236- epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
237 author_date = datetime(2015, 1, 1, tzinfo=pytz.UTC)
238 committer_date = datetime(2015, 1, 2, tzinfo=pytz.UTC)
239 hosting_fixture = self.useFixture(GitHostingFixture(commits=[
240@@ -1293,12 +1293,12 @@
241 "author": {
242 "name": author.displayname,
243 "email": author_email,
244- "time": int((author_date - epoch).total_seconds()),
245+ "time": int(seconds_since_epoch(author_date)),
246 },
247 "committer": {
248 "name": "New Person",
249 "email": "new-person@example.org",
250- "time": int((committer_date - epoch).total_seconds()),
251+ "time": int(seconds_since_epoch(committer_date)),
252 },
253 "parents": [],
254 "tree": unicode(hashlib.sha1("").hexdigest()),
255
256=== modified file 'lib/lp/code/stories/branches/xx-code-review-comments.txt'
257--- lib/lp/code/stories/branches/xx-code-review-comments.txt 2018-05-13 10:35:52 +0000
258+++ lib/lp/code/stories/branches/xx-code-review-comments.txt 2018-10-03 00:52:37 +0000
259@@ -192,11 +192,11 @@
260 log entries first.
261
262 >>> from lp.code.tests.helpers import GitHostingFixture
263+ >>> from lp.services.utils import seconds_since_epoch
264
265 >>> login('admin@canonical.com')
266 >>> bmp = factory.makeBranchMergeProposalForGit()
267 >>> bmp.requestReview(review_date)
268- >>> epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
269 >>> commit_date = review_date + timedelta(days=1)
270 >>> hosting_fixture = GitHostingFixture()
271 >>> for i in range(2):
272@@ -206,7 +206,7 @@
273 ... u'author': {
274 ... u'name': bmp.registrant.display_name,
275 ... u'email': bmp.registrant.preferredemail.email,
276- ... u'time': int((commit_date - epoch).total_seconds()),
277+ ... u'time': int(seconds_since_epoch(commit_date)),
278 ... },
279 ... })
280 ... hosting_fixture.getLog.result.insert(0, {
281@@ -215,7 +215,7 @@
282 ... u'author': {
283 ... u'name': bmp.registrant.display_name,
284 ... u'email': bmp.registrant.preferredemail.email,
285- ... u'time': int((commit_date - epoch).total_seconds()),
286+ ... u'time': int(seconds_since_epoch(commit_date)),
287 ... },
288 ... })
289 ... commit_date += timedelta(days=1)
290
291=== modified file 'lib/lp/services/tests/test_utils.py'
292--- lib/lp/services/tests/test_utils.py 2018-04-17 09:41:46 +0000
293+++ lib/lp/services/tests/test_utils.py 2018-10-03 00:52:37 +0000
294@@ -37,6 +37,7 @@
295 run_capturing_output,
296 sanitise_urls,
297 save_bz2_pickle,
298+ seconds_since_epoch,
299 traceback_info,
300 utc_now,
301 )
302@@ -380,6 +381,18 @@
303 self.assertThat(now, LessThanOrEqual(new_now))
304
305
306+class TestSecondsSinceEpoch(TestCase):
307+ """Tests for `seconds_since_epoch`."""
308+
309+ def test_epoch(self):
310+ epoch = datetime.fromtimestamp(0, tz=UTC)
311+ self.assertEqual(0, seconds_since_epoch(epoch))
312+
313+ def test_start_of_2018(self):
314+ dt = datetime(2018, 1, 1, tzinfo=UTC)
315+ self.assertEqual(1514764800, seconds_since_epoch(dt))
316+
317+
318 class TestBZ2Pickle(TestCase):
319 """Tests for `save_bz2_pickle` and `load_bz2_pickle`."""
320
321
322=== modified file 'lib/lp/services/utils.py'
323--- lib/lp/services/utils.py 2018-04-17 09:41:46 +0000
324+++ lib/lp/services/utils.py 2018-10-03 00:52:37 +0000
325@@ -25,6 +25,7 @@
326 'run_capturing_output',
327 'sanitise_urls',
328 'save_bz2_pickle',
329+ 'seconds_since_epoch',
330 'text_delta',
331 'traceback_info',
332 'utc_now',
333@@ -302,6 +303,14 @@
334 return datetime.now(tz=pytz.UTC)
335
336
337+_epoch = datetime.fromtimestamp(0, tz=pytz.UTC)
338+
339+
340+def seconds_since_epoch(dt):
341+ """Express a `datetime` as the number of seconds since the Unix epoch."""
342+ return (dt - _epoch).total_seconds()
343+
344+
345 # This is a regular expression that matches email address embedded in
346 # text. It is not RFC 2821 compliant, nor does it need to be. This
347 # expression strives to identify probable email addresses so that they
348
349=== modified file 'lib/lp/snappy/browser/snap.py'
350--- lib/lp/snappy/browser/snap.py 2018-09-13 15:21:05 +0000
351+++ lib/lp/snappy/browser/snap.py 2018-10-03 00:52:37 +0000
352@@ -57,7 +57,9 @@
353 from lp.registry.interfaces.pocket import PackagePublishingPocket
354 from lp.services.features import getFeatureFlag
355 from lp.services.helpers import english_list
356+from lp.services.propertycache import cachedproperty
357 from lp.services.scripts import log
358+from lp.services.utils import seconds_since_epoch
359 from lp.services.webapp import (
360 canonical_url,
361 ContextMenu,
362@@ -178,9 +180,9 @@
363 class SnapView(LaunchpadView):
364 """Default view of a Snap."""
365
366- @property
367- def builds(self):
368- return builds_for_snap(self.context)
369+ @cachedproperty
370+ def builds_and_requests(self):
371+ return builds_and_requests_for_snap(self.context)
372
373 @property
374 def person_picker(self):
375@@ -209,23 +211,47 @@
376 return ', '.join(self.context.store_channels)
377
378
379-def builds_for_snap(snap):
380- """A list of interesting builds.
381+def builds_and_requests_for_snap(snap):
382+ """A list of interesting builds and build requests.
383
384- All pending builds are shown, as well as 1-10 recent builds. Recent
385- builds are ordered by date finished (if completed) or date_started (if
386- date finished is not set due to an error building or other circumstance
387- which resulted in the build not being completed). This allows started
388- but unfinished builds to show up in the view but be discarded as more
389- recent builds become available.
390+ All pending builds and pending build requests are shown, as well as up
391+ to 10 recent builds and recent failed build requests. Pending items are
392+ ordered by the date they were created; recent items are ordered by the
393+ date they finished (if available) or the date they started (if the date
394+ they finished is not set due to an error). This allows started but
395+ unfinished builds to show up in the view but be discarded as more recent
396+ builds become available.
397
398 Builds that the user does not have permission to see are excluded (by
399 the model code).
400 """
401- builds = list(snap.pending_builds)
402- if len(builds) < 10:
403- builds.extend(snap.completed_builds[:10 - len(builds)])
404- return builds
405+ # We need to interleave items of different types, so SQL can't do all
406+ # the sorting for us.
407+ def make_sort_key(*date_attrs):
408+ def _sort_key(item):
409+ for date_attr in date_attrs:
410+ if getattr(item, date_attr, None) is not None:
411+ return -seconds_since_epoch(getattr(item, date_attr))
412+ return 0
413+
414+ return _sort_key
415+
416+ items = sorted(
417+ list(snap.pending_builds) + list(snap.pending_build_requests),
418+ key=make_sort_key("date_created", "date_requested"))
419+ if len(items) < 10:
420+ # We need to interleave two unbounded result sets, but we only need
421+ # enough items from them to make the total count up to 10. It's
422+ # simplest to just fetch the upper bound from each set and do our
423+ # own sorting.
424+ recent_items = sorted(
425+ list(snap.completed_builds[:10 - len(items)]) +
426+ list(snap.failed_build_requests[:10 - len(items)]),
427+ key=make_sort_key(
428+ "date_finished", "date_started",
429+ "date_created", "date_requested"))
430+ items.extend(recent_items[:10 - len(items)])
431+ return items
432
433
434 def new_builds_notification_text(builds, already_pending=None):
435
436=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
437--- lib/lp/snappy/browser/tests/test_snap.py 2018-09-13 15:21:05 +0000
438+++ lib/lp/snappy/browser/tests/test_snap.py 2018-10-03 00:52:37 +0000
439@@ -29,6 +29,7 @@
440 AfterPreprocessing,
441 Equals,
442 Is,
443+ MatchesListwise,
444 MatchesSetwise,
445 MatchesStructure,
446 )
447@@ -56,6 +57,8 @@
448 from lp.services.config import config
449 from lp.services.database.constants import UTC_NOW
450 from lp.services.features.testing import FeatureFixture
451+from lp.services.job.interfaces.job import JobStatus
452+from lp.services.propertycache import get_property_cache
453 from lp.services.webapp import canonical_url
454 from lp.services.webapp.servers import LaunchpadTestRequest
455 from lp.snappy.browser.snap import (
456@@ -1344,7 +1347,7 @@
457 "This snap package has not been built yet.",
458 self.getMainText(snap))
459
460- def test_index_pending(self):
461+ def test_index_pending_build(self):
462 # A pending build is listed as such.
463 build = self.makeBuild()
464 build.queueBuild()
465@@ -1355,6 +1358,38 @@
466 Primary Archive for Ubuntu Linux
467 """, self.getMainText(build.snap))
468
469+ def test_index_pending_build_request(self):
470+ # A pending build request is listed as such.
471+ snap = self.makeSnap()
472+ with person_logged_in(snap.owner):
473+ snap.requestBuilds(
474+ snap.owner, snap.distro_series.main_archive,
475+ PackagePublishingPocket.UPDATES)
476+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
477+ Latest builds
478+ Status When complete Architecture Archive
479+ Pending build request
480+ Primary Archive for Ubuntu Linux
481+ """, self.getMainText(snap))
482+
483+ def test_index_failed_build_request(self):
484+ # A failed build request is listed as such, with its error message.
485+ snap = self.makeSnap()
486+ with person_logged_in(snap.owner):
487+ request = snap.requestBuilds(
488+ snap.owner, snap.distro_series.main_archive,
489+ PackagePublishingPocket.UPDATES)
490+ job = removeSecurityProxy(removeSecurityProxy(request)._job)
491+ job.job._status = JobStatus.FAILED
492+ job.job.date_finished = datetime.now(pytz.UTC) - timedelta(hours=1)
493+ job.error_message = "Boom"
494+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
495+ Latest builds
496+ Status When complete Architecture Archive
497+ Failed build request 1 hour ago \(Boom\)
498+ Primary Archive for Ubuntu Linux
499+ """, self.getMainText(snap))
500+
501 def test_index_store_upload(self):
502 # If the snap package is to be automatically uploaded to the store,
503 # the index page shows details of this.
504@@ -1382,8 +1417,8 @@
505 build.updateStatus(
506 status, date_finished=build.date_started + timedelta(minutes=30))
507
508- def test_builds(self):
509- # SnapView.builds produces reasonable results.
510+ def test_builds_and_requests(self):
511+ # SnapView.builds_and_requests produces reasonable results.
512 snap = self.makeSnap()
513 # Create oldest builds first so that they sort properly by id.
514 date_gen = time_counter(
515@@ -1392,16 +1427,67 @@
516 self.makeBuild(snap=snap, date_created=next(date_gen))
517 for i in range(11)]
518 view = SnapView(snap, None)
519- self.assertEqual(list(reversed(builds)), view.builds)
520+ self.assertEqual(list(reversed(builds)), view.builds_and_requests)
521 self.setStatus(builds[10], BuildStatus.FULLYBUILT)
522 self.setStatus(builds[9], BuildStatus.FAILEDTOBUILD)
523+ del get_property_cache(view).builds_and_requests
524 # When there are >= 9 pending builds, only the most recent of any
525 # completed builds is returned.
526 self.assertEqual(
527- list(reversed(builds[:9])) + [builds[10]], view.builds)
528+ list(reversed(builds[:9])) + [builds[10]],
529+ view.builds_and_requests)
530 for build in builds[:9]:
531 self.setStatus(build, BuildStatus.FULLYBUILT)
532- self.assertEqual(list(reversed(builds[1:])), view.builds)
533+ del get_property_cache(view).builds_and_requests
534+ self.assertEqual(list(reversed(builds[1:])), view.builds_and_requests)
535+
536+ def test_builds_and_requests_shows_build_requests(self):
537+ # SnapView.builds_and_requests interleaves build requests with
538+ # builds.
539+ snap = self.makeSnap()
540+ date_gen = time_counter(
541+ datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
542+ builds = [
543+ self.makeBuild(snap=snap, date_created=next(date_gen))
544+ for i in range(3)]
545+ self.setStatus(builds[2], BuildStatus.FULLYBUILT)
546+ with person_logged_in(snap.owner):
547+ request = snap.requestBuilds(
548+ snap.owner, snap.distro_series.main_archive,
549+ PackagePublishingPocket.UPDATES)
550+ job = removeSecurityProxy(removeSecurityProxy(request)._job)
551+ job.job.date_created = next(date_gen)
552+ view = SnapView(snap, None)
553+ # The pending build request is interleaved in date order with
554+ # pending builds, and these are followed by completed builds.
555+ self.assertThat(view.builds_and_requests, MatchesListwise([
556+ MatchesStructure.byEquality(id=request.id),
557+ Equals(builds[1]),
558+ Equals(builds[0]),
559+ Equals(builds[2]),
560+ ]))
561+ transaction.commit()
562+ builds.append(self.makeBuild(snap=snap))
563+ del get_property_cache(view).builds_and_requests
564+ self.assertThat(view.builds_and_requests, MatchesListwise([
565+ Equals(builds[3]),
566+ MatchesStructure.byEquality(id=request.id),
567+ Equals(builds[1]),
568+ Equals(builds[0]),
569+ Equals(builds[2]),
570+ ]))
571+ # If we pretend that the job failed, it is still listed, but after
572+ # any pending builds.
573+ job.job._status = JobStatus.FAILED
574+ job.job.date_finished = job.date_created + timedelta(minutes=30)
575+ del get_property_cache(view).builds_and_requests
576+ self.assertThat(view.builds_and_requests, MatchesListwise([
577+ Equals(builds[3]),
578+ Equals(builds[1]),
579+ Equals(builds[0]),
580+ MatchesStructure.byEquality(id=request.id),
581+ Equals(builds[2]),
582+ ]))
583
584 def test_store_channels_empty(self):
585 snap = self.factory.makeSnap()
586
587=== modified file 'lib/lp/snappy/interfaces/snap.py'
588--- lib/lp/snappy/interfaces/snap.py 2018-09-13 15:21:05 +0000
589+++ lib/lp/snappy/interfaces/snap.py 2018-10-03 00:52:37 +0000
590@@ -459,6 +459,11 @@
591 value_type=Reference(ISnapBuildRequest),
592 required=True, readonly=True)))
593
594+ failed_build_requests = exported(doNotSnapshot(CollectionField(
595+ title=_("Failed build requests for this snap package."),
596+ value_type=Reference(ISnapBuildRequest),
597+ required=True, readonly=True)))
598+
599 # XXX cjwatson 2018-06-20: Deprecated as an exported method; can become
600 # an internal helper method once production JavaScript no longer uses
601 # it.
602
603=== modified file 'lib/lp/snappy/model/snap.py'
604--- lib/lp/snappy/model/snap.py 2018-09-13 15:21:05 +0000
605+++ lib/lp/snappy/model/snap.py 2018-10-03 00:52:37 +0000
606@@ -674,7 +674,17 @@
607 # The returned jobs are ordered by descending ID.
608 jobs = job_source.findBySnap(
609 self, statuses=(JobStatus.WAITING, JobStatus.RUNNING))
610- return [SnapBuildRequest.fromJob(job) for job in jobs]
611+ return DecoratedResultSet(
612+ jobs, result_decorator=SnapBuildRequest.fromJob)
613+
614+ @property
615+ def failed_build_requests(self):
616+ """See `ISnap`."""
617+ job_source = getUtility(ISnapRequestBuildsJobSource)
618+ # The returned jobs are ordered by descending ID.
619+ jobs = job_source.findBySnap(self, statuses=(JobStatus.FAILED,))
620+ return DecoratedResultSet(
621+ jobs, result_decorator=SnapBuildRequest.fromJob)
622
623 def _getBuilds(self, filter_term, order_by):
624 """The actual query to get the builds."""
625
626=== modified file 'lib/lp/snappy/templates/snap-index.pt'
627--- lib/lp/snappy/templates/snap-index.pt 2018-09-13 15:21:05 +0000
628+++ lib/lp/snappy/templates/snap-index.pt 2018-10-03 00:52:37 +0000
629@@ -156,51 +156,62 @@
630 </tr>
631 </thead>
632 <tbody>
633- <tal:snap-build-requests repeat="request context/pending_build_requests">
634- <tr tal:attributes="id string:request-${request/id}">
635- <td colspan="3"
636- tal:attributes="class string:request_status ${request/status/name}">
637- <span tal:replace="structure request/image:icon"/>
638- <tal:title replace="request/status/title"/> build request
639- </td>
640- <td>
641- <tal:archive replace="structure request/archive/fmt:link"/>
642- </td>
643- </tr>
644- </tal:snap-build-requests>
645- <tal:snap-builds repeat="build view/builds">
646- <tr tal:attributes="id string:build-${build/id}">
647- <td tal:attributes="class string:build_status ${build/status/name}">
648- <span tal:replace="structure build/image:icon"/>
649- <a tal:content="build/status/title"
650- tal:attributes="href build/fmt:url"/>
651- </td>
652- <td class="datebuilt">
653- <tal:date replace="build/date/fmt:displaydate"/>
654- <tal:estimate condition="build/estimate">
655- (estimated)
656- </tal:estimate>
657+ <tal:snap-builds-and-requests repeat="item view/builds_and_requests">
658+ <tal:snap-build-request condition="item/date_requested|nothing">
659+ <tr tal:define="request item"
660+ tal:attributes="id string:request-${request/id}">
661+ <td tal:attributes="class string:request_status ${request/status/name}">
662+ <span tal:replace="structure request/image:icon"/>
663+ <tal:title replace="request/status/title"/> build request
664+ </td>
665+ <td>
666+ <tal:date condition="request/date_finished"
667+ replace="request/date_finished/fmt:displaydate"/>
668+ <tal:error-message condition="request/error_message">
669+ (<span tal:replace="request/error_message"/>)
670+ </tal:error-message>
671+ </td>
672+ <td/>
673+ <td>
674+ <tal:archive replace="structure request/archive/fmt:link"/>
675+ </td>
676+ </tr>
677+ </tal:snap-build-request>
678+ <tal:snap-build condition="not: item/date_requested|nothing">
679+ <tr tal:define="build item"
680+ tal:attributes="id string:build-${build/id}">
681+ <td tal:attributes="class string:build_status ${build/status/name}">
682+ <span tal:replace="structure build/image:icon"/>
683+ <a tal:content="build/status/title"
684+ tal:attributes="href build/fmt:url"/>
685+ </td>
686+ <td class="datebuilt">
687+ <tal:date replace="build/date/fmt:displaydate"/>
688+ <tal:estimate condition="build/estimate">
689+ (estimated)
690+ </tal:estimate>
691
692- <tal:build-log define="file build/log" tal:condition="file">
693- <a class="sprite download"
694- tal:attributes="href build/log_url">buildlog</a>
695- (<span tal:replace="file/content/filesize/fmt:bytes"/>)
696- </tal:build-log>
697- </td>
698- <td>
699- <a class="sprite distribution"
700- tal:define="archseries build/distro_arch_series"
701- tal:attributes="href archseries/fmt:url"
702- tal:content="archseries/architecturetag"/>
703- </td>
704- <td>
705- <tal:archive replace="structure build/archive/fmt:link"/>
706- </td>
707- </tr>
708- </tal:snap-builds>
709+ <tal:build-log define="file build/log" tal:condition="file">
710+ <a class="sprite download"
711+ tal:attributes="href build/log_url">buildlog</a>
712+ (<span tal:replace="file/content/filesize/fmt:bytes"/>)
713+ </tal:build-log>
714+ </td>
715+ <td>
716+ <a class="sprite distribution"
717+ tal:define="archseries build/distro_arch_series"
718+ tal:attributes="href archseries/fmt:url"
719+ tal:content="archseries/architecturetag"/>
720+ </td>
721+ <td>
722+ <tal:archive replace="structure build/archive/fmt:link"/>
723+ </td>
724+ </tr>
725+ </tal:snap-build>
726+ </tal:snap-builds-and-requests>
727 </tbody>
728 </table>
729- <p tal:condition="not: view/builds">
730+ <p tal:condition="not: view/builds_and_requests">
731 This snap package has not been built yet.
732 </p>
733 <div tal:define="link context/menu:context/request_builds"
734
735=== modified file 'lib/lp/snappy/tests/test_snap.py'
736--- lib/lp/snappy/tests/test_snap.py 2018-09-13 15:21:05 +0000
737+++ lib/lp/snappy/tests/test_snap.py 2018-10-03 00:52:37 +0000
738@@ -177,7 +177,7 @@
739 self.assertThat(
740 self.factory.makeSnap(),
741 DoesNotSnapshot(
742- ["pending_build_requests",
743+ ["pending_build_requests", "failed_build_requests",
744 "builds", "completed_builds", "pending_builds"],
745 ISnapView))
746