Merge ~cjwatson/launchpad:ci-build-basic-views into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 7a94e083c202d8133620c7231bc658bc871ac495
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:ci-build-basic-views
Merge into: launchpad:master
Diff against target: 745 lines (+645/-0)
9 files modified
lib/lp/code/browser/cibuild.py (+148/-0)
lib/lp/code/browser/configure.zcml (+38/-0)
lib/lp/code/browser/gitrepository.py (+9/-0)
lib/lp/code/browser/tests/test_cibuild.py (+202/-0)
lib/lp/code/interfaces/cibuild.py (+13/-0)
lib/lp/code/model/cibuild.py (+14/-0)
lib/lp/code/model/tests/test_cibuild.py (+12/-0)
lib/lp/code/templates/cibuild-index.pt (+181/-0)
lib/lp/code/templates/cibuild-retry.pt (+28/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+417630@code.launchpad.net

Commit message

Add basic index/retry/cancel/rescore views for CI builds

Description of the change

This should be just enough to let us deal with build farm administration as it relates to CI builds.

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/browser/cibuild.py b/lib/lp/code/browser/cibuild.py
2new file mode 100644
3index 0000000..e03d7b7
4--- /dev/null
5+++ b/lib/lp/code/browser/cibuild.py
6@@ -0,0 +1,148 @@
7+# Copyright 2022 Canonical Ltd. This software is licensed under the
8+# GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+"""CI build views."""
11+
12+__all__ = [
13+ "CIBuildContextMenu",
14+ "CIBuildNavigation",
15+ "CIBuildView",
16+ ]
17+
18+from zope.interface import Interface
19+
20+from lp.app.browser.launchpadform import (
21+ action,
22+ LaunchpadFormView,
23+ )
24+from lp.code.interfaces.cibuild import ICIBuild
25+from lp.services.librarian.browser import FileNavigationMixin
26+from lp.services.webapp import (
27+ canonical_url,
28+ ContextMenu,
29+ enabled_with_permission,
30+ Link,
31+ Navigation,
32+ )
33+from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
34+
35+
36+class CIBuildNavigation(Navigation, FileNavigationMixin):
37+ usedfor = ICIBuild
38+
39+
40+class CIBuildContextMenu(ContextMenu):
41+ """Context menu for CI builds."""
42+
43+ usedfor = ICIBuild
44+
45+ facet = "overview"
46+
47+ links = ("retry", "cancel", "rescore")
48+
49+ @enabled_with_permission("launchpad.Edit")
50+ def retry(self):
51+ return Link(
52+ "+retry", "Retry this build", icon="retry",
53+ enabled=self.context.can_be_retried)
54+
55+ @enabled_with_permission("launchpad.Edit")
56+ def cancel(self):
57+ return Link(
58+ "+cancel", "Cancel build", icon="remove",
59+ enabled=self.context.can_be_cancelled)
60+
61+ @enabled_with_permission("launchpad.Admin")
62+ def rescore(self):
63+ return Link(
64+ "+rescore", "Rescore build", icon="edit",
65+ enabled=self.context.can_be_rescored)
66+
67+
68+class CIBuildView(LaunchpadFormView):
69+ """Default view of a CI build."""
70+
71+ class schema(Interface):
72+ """Schema for a build."""
73+
74+ @property
75+ def label(self):
76+ return self.context.title
77+
78+ page_title = label
79+
80+
81+class CIBuildRetryView(LaunchpadFormView):
82+ """View for retrying a CI build."""
83+
84+ class schema(Interface):
85+ """Schema for retrying a build."""
86+
87+ page_title = label = "Retry build"
88+
89+ @property
90+ def cancel_url(self):
91+ return canonical_url(self.context)
92+ next_url = cancel_url
93+
94+ @action("Retry build", name="retry")
95+ def request_action(self, action, data):
96+ """Retry the build."""
97+ if not self.context.can_be_retried:
98+ self.request.response.addErrorNotification(
99+ "Build cannot be retried")
100+ else:
101+ self.context.retry()
102+ self.request.response.addInfoNotification("Build has been queued")
103+
104+ self.request.response.redirect(self.next_url)
105+
106+
107+class CIBuildCancelView(LaunchpadFormView):
108+ """View for cancelling a CI build."""
109+
110+ class schema(Interface):
111+ """Schema for cancelling a build."""
112+
113+ page_title = label = "Cancel build"
114+
115+ @property
116+ def cancel_url(self):
117+ return canonical_url(self.context)
118+ next_url = cancel_url
119+
120+ @action("Cancel build", name="cancel")
121+ def request_action(self, action, data):
122+ """Cancel the build."""
123+ self.context.cancel()
124+
125+
126+class CIBuildRescoreView(LaunchpadFormView):
127+ """View for rescoring a CI build."""
128+
129+ schema = IBuildRescoreForm
130+
131+ page_title = label = "Rescore build"
132+
133+ def __call__(self):
134+ if self.context.can_be_rescored:
135+ return super().__call__()
136+ self.request.response.addWarningNotification(
137+ "Cannot rescore this build because it is not queued.")
138+ self.request.response.redirect(canonical_url(self.context))
139+
140+ @property
141+ def cancel_url(self):
142+ return canonical_url(self.context)
143+ next_url = cancel_url
144+
145+ @action("Rescore build", name="rescore")
146+ def request_action(self, action, data):
147+ """Rescore the build."""
148+ score = data.get("priority")
149+ self.context.rescore(score)
150+ self.request.response.addNotification("Build rescored to %s." % score)
151+
152+ @property
153+ def initial_values(self):
154+ return {"score": str(self.context.buildqueue_record.lastscore)}
155diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
156index 5520698..3f16bf4 100644
157--- a/lib/lp/code/browser/configure.zcml
158+++ b/lib/lp/code/browser/configure.zcml
159@@ -1422,6 +1422,44 @@
160 for="lp.code.interfaces.cibuild.ICIBuild"
161 path_expression="string:+build/${id}"
162 attribute_to_parent="git_repository"/>
163+ <browser:menus
164+ module="lp.code.browser.cibuild"
165+ classes="CIBuildContextMenu" />
166+ <browser:navigation
167+ module="lp.code.browser.cibuild"
168+ classes="CIBuildNavigation" />
169+ <browser:defaultView
170+ for="lp.code.interfaces.cibuild.ICIBuild"
171+ name="+index" />
172+ <browser:page
173+ for="lp.code.interfaces.cibuild.ICIBuild"
174+ class="lp.code.browser.cibuild.CIBuildView"
175+ permission="launchpad.View"
176+ name="+index"
177+ template="../templates/cibuild-index.pt" />
178+ <browser:page
179+ for="lp.code.interfaces.cibuild.ICIBuild"
180+ class="lp.code.browser.cibuild.CIBuildRetryView"
181+ permission="launchpad.Edit"
182+ name="+retry"
183+ template="../templates/cibuild-retry.pt" />
184+ <browser:page
185+ for="lp.code.interfaces.cibuild.ICIBuild"
186+ class="lp.code.browser.cibuild.CIBuildCancelView"
187+ permission="launchpad.Edit"
188+ name="+cancel"
189+ template="../../app/templates/generic-edit.pt" />
190+ <browser:page
191+ for="lp.code.interfaces.cibuild.ICIBuild"
192+ class="lp.code.browser.cibuild.CIBuildRescoreView"
193+ permission="launchpad.Admin"
194+ name="+rescore"
195+ template="../../app/templates/generic-edit.pt" />
196+ <adapter
197+ provides="lp.services.webapp.interfaces.IBreadcrumb"
198+ for="lp.code.interfaces.cibuild.ICIBuild"
199+ factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
200+ permission="zope.Public" />
201
202 </facet>
203
204diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
205index f0b6668..072f626 100644
206--- a/lib/lp/code/browser/gitrepository.py
207+++ b/lib/lp/code/browser/gitrepository.py
208@@ -99,6 +99,7 @@ from lp.code.errors import (
209 GitRepositoryExists,
210 GitTargetError,
211 )
212+from lp.code.interfaces.cibuild import ICIBuildSet
213 from lp.code.interfaces.gitnamespace import get_git_namespace
214 from lp.code.interfaces.gitref import IGitRefBatchNavigator
215 from lp.code.interfaces.gitrepository import (
216@@ -145,6 +146,7 @@ from lp.services.webapp.publisher import DataDownloadView
217 from lp.services.webapp.snapshot import notify_modified
218 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
219 from lp.snappy.browser.hassnaps import HasSnapsViewMixin
220+from lp.soyuz.browser.build import get_build_by_id_str
221
222
223 GIT_REPOSITORY_FORK_ENABLED = 'gitrepository.fork.enabled'
224@@ -270,6 +272,13 @@ class GitRepositoryNavigation(WebhookTargetNavigationMixin, Navigation):
225 """Traverses to the `ICodeImport` for the repository."""
226 return self.context.code_import
227
228+ @stepthrough("+build")
229+ def traverse_build(self, name):
230+ build = get_build_by_id_str(ICIBuildSet, name)
231+ if build is None or build.git_repository != self.context:
232+ return None
233+ return build
234+
235
236 class GitRepositoryEditMenu(NavigationMenu):
237 """Edit menu for `IGitRepository`."""
238diff --git a/lib/lp/code/browser/tests/test_cibuild.py b/lib/lp/code/browser/tests/test_cibuild.py
239new file mode 100644
240index 0000000..662b830
241--- /dev/null
242+++ b/lib/lp/code/browser/tests/test_cibuild.py
243@@ -0,0 +1,202 @@
244+# Copyright 2022 Canonical Ltd. This software is licensed under the
245+# GNU Affero General Public License version 3 (see the file LICENSE).
246+
247+"""Test CI build views."""
248+
249+import re
250+
251+from fixtures import FakeLogger
252+import soupmatchers
253+from storm.locals import Store
254+import transaction
255+from zope.component import getUtility
256+from zope.security.interfaces import Unauthorized
257+from zope.testbrowser.browser import LinkNotFoundError
258+
259+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
260+from lp.buildmaster.enums import BuildStatus
261+from lp.services.webapp import canonical_url
262+from lp.testing import (
263+ ANONYMOUS,
264+ BrowserTestCase,
265+ login,
266+ person_logged_in,
267+ TestCaseWithFactory,
268+ )
269+from lp.testing.layers import DatabaseFunctionalLayer
270+from lp.testing.pages import (
271+ extract_text,
272+ find_main_content,
273+ find_tags_by_class,
274+ )
275+
276+
277+class TestCanonicalUrlForCIBuild(TestCaseWithFactory):
278+
279+ layer = DatabaseFunctionalLayer
280+
281+ def test_canonical_url(self):
282+ repository = self.factory.makeGitRepository()
283+ build = self.factory.makeCIBuild(git_repository=repository)
284+ self.assertEqual(
285+ "http://launchpad.test/%s/+build/%s" % (
286+ repository.shortened_path, build.id),
287+ canonical_url(build))
288+
289+
290+class TestCIBuildOperations(BrowserTestCase):
291+
292+ layer = DatabaseFunctionalLayer
293+
294+ def setUp(self):
295+ super().setUp()
296+ self.useFixture(FakeLogger())
297+ self.build = self.factory.makeCIBuild()
298+ self.build_url = canonical_url(self.build)
299+ self.repository = self.build.git_repository
300+ self.buildd_admin = self.factory.makePerson(
301+ member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
302+
303+ def test_retry_build(self):
304+ # The owner of a build's repository can retry it.
305+ self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
306+ transaction.commit()
307+ browser = self.getViewBrowser(self.build, user=self.repository.owner)
308+ browser.getLink("Retry this build").click()
309+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
310+ browser.getControl("Retry build").click()
311+ self.assertEqual(self.build_url, browser.url)
312+ login(ANONYMOUS)
313+ self.assertEqual(BuildStatus.NEEDSBUILD, self.build.status)
314+
315+ def test_retry_build_random_user(self):
316+ # An unrelated non-admin user cannot retry a build.
317+ self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
318+ transaction.commit()
319+ user = self.factory.makePerson()
320+ browser = self.getViewBrowser(self.build, user=user)
321+ self.assertRaises(
322+ LinkNotFoundError, browser.getLink, "Retry this build")
323+ self.assertRaises(
324+ Unauthorized, self.getUserBrowser, self.build_url + "/+retry",
325+ user=user)
326+
327+ def test_retry_build_wrong_state(self):
328+ # If the build isn't in an unsuccessful terminal state, you can't
329+ # retry it.
330+ self.build.updateStatus(BuildStatus.FULLYBUILT)
331+ browser = self.getViewBrowser(self.build, user=self.repository.owner)
332+ self.assertRaises(
333+ LinkNotFoundError, browser.getLink, "Retry this build")
334+
335+ def test_cancel_build(self):
336+ # The owner of a build's repository can cancel it.
337+ self.build.queueBuild()
338+ transaction.commit()
339+ browser = self.getViewBrowser(self.build, user=self.repository.owner)
340+ browser.getLink("Cancel build").click()
341+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
342+ browser.getControl("Cancel build").click()
343+ self.assertEqual(self.build_url, browser.url)
344+ login(ANONYMOUS)
345+ self.assertEqual(BuildStatus.CANCELLED, self.build.status)
346+
347+ def test_cancel_build_random_user(self):
348+ # An unrelated non-admin user cannot cancel a build.
349+ self.build.queueBuild()
350+ transaction.commit()
351+ user = self.factory.makePerson()
352+ browser = self.getViewBrowser(self.build, user=user)
353+ self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
354+ self.assertRaises(
355+ Unauthorized, self.getUserBrowser, self.build_url + "/+cancel",
356+ user=user)
357+
358+ def test_cancel_build_wrong_state(self):
359+ # If the build isn't queued, you can't cancel it.
360+ browser = self.getViewBrowser(self.build, user=self.repository.owner)
361+ self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
362+
363+ def test_rescore_build(self):
364+ # A buildd admin can rescore a build.
365+ self.build.queueBuild()
366+ transaction.commit()
367+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
368+ browser.getLink("Rescore build").click()
369+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
370+ browser.getControl("Priority").value = "1024"
371+ browser.getControl("Rescore build").click()
372+ self.assertEqual(self.build_url, browser.url)
373+ login(ANONYMOUS)
374+ self.assertEqual(1024, self.build.buildqueue_record.lastscore)
375+
376+ def test_rescore_build_invalid_score(self):
377+ # Build scores can only take numbers.
378+ self.build.queueBuild()
379+ transaction.commit()
380+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
381+ browser.getLink("Rescore build").click()
382+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
383+ browser.getControl("Priority").value = "tentwentyfour"
384+ browser.getControl("Rescore build").click()
385+ self.assertEqual(
386+ "Invalid integer data",
387+ extract_text(find_tags_by_class(browser.contents, "message")[1]))
388+
389+ def test_rescore_build_not_admin(self):
390+ # A non-admin user cannot cancel a build.
391+ self.build.queueBuild()
392+ transaction.commit()
393+ user = self.factory.makePerson()
394+ browser = self.getViewBrowser(self.build, user=user)
395+ self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
396+ self.assertRaises(
397+ Unauthorized, self.getUserBrowser, self.build_url + "/+rescore",
398+ user=user)
399+
400+ def test_rescore_build_wrong_state(self):
401+ # If the build isn't NEEDSBUILD, you can't rescore it.
402+ self.build.queueBuild()
403+ with person_logged_in(self.repository.owner):
404+ self.build.cancel()
405+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
406+ self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
407+
408+ def test_rescore_build_wrong_state_stale_link(self):
409+ # An attempt to rescore a non-queued build from a stale link shows a
410+ # sensible error message.
411+ self.build.queueBuild()
412+ with person_logged_in(self.repository.owner):
413+ self.build.cancel()
414+ browser = self.getViewBrowser(
415+ self.build, "+rescore", user=self.buildd_admin)
416+ self.assertEqual(self.build_url, browser.url)
417+ self.assertThat(browser.contents, soupmatchers.HTMLContains(
418+ soupmatchers.Tag(
419+ "notification", "div", attrs={"class": "warning message"},
420+ text="Cannot rescore this build because it is not queued.")))
421+
422+ def test_builder_history(self):
423+ Store.of(self.build).flush()
424+ self.build.updateStatus(
425+ BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder())
426+ title = self.build.title
427+ browser = self.getViewBrowser(self.build.builder, "+history")
428+ self.assertTextMatchesExpressionIgnoreWhitespace(
429+ r"Build history.*%s" % re.escape(title),
430+ extract_text(find_main_content(browser.contents)))
431+ self.assertEqual(self.build_url, browser.getLink(title).url)
432+
433+ def makeBuildingRecipe(self):
434+ builder = self.factory.makeBuilder()
435+ build = self.factory.makeCIBuild()
436+ build.updateStatus(BuildStatus.BUILDING, builder=builder)
437+ build.queueBuild()
438+ build.buildqueue_record.builder = builder
439+ build.buildqueue_record.logtail = "tail of the log"
440+ return build
441+
442+ def test_builder_index_public(self):
443+ build = self.makeBuildingRecipe()
444+ browser = self.getViewBrowser(build.builder, no_login=True)
445+ self.assertIn("tail of the log", browser.contents)
446diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
447index e1abcf8..470cd95 100644
448--- a/lib/lp/code/interfaces/cibuild.py
449+++ b/lib/lp/code/interfaces/cibuild.py
450@@ -130,6 +130,19 @@ class ICIBuildView(IPackageBuildView):
451 cannot be parsed.
452 """
453
454+ def getFileByName(filename):
455+ """Return the corresponding `ILibraryFileAlias` in this context.
456+
457+ The following file types (and extension) can be looked up:
458+
459+ * Build log: '.txt.gz'
460+ * Upload log: '_log.txt'
461+
462+ :param filename: The filename to look up.
463+ :raises NotFoundError: if no file exists with the given name.
464+ :return: The corresponding `ILibraryFileAlias`.
465+ """
466+
467
468 class ICIBuildEdit(IBuildFarmJobEdit):
469 """`ICIBuild` methods that require launchpad.Edit."""
470diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
471index 4da34fb..785b1cc 100644
472--- a/lib/lp/code/model/cibuild.py
473+++ b/lib/lp/code/model/cibuild.py
474@@ -359,6 +359,20 @@ class CIBuild(PackageBuildMixin, StormBase):
475 "%s: %s" % (msg % self.git_repository.unique_name, e))
476 return parse_configuration(self.git_repository, blob)
477
478+ def getFileByName(self, filename):
479+ """See `ICIBuild`."""
480+ if filename.endswith(".txt.gz"):
481+ file_object = self.log
482+ elif filename.endswith("_log.txt"):
483+ file_object = self.upload_log
484+ else:
485+ file_object = None
486+
487+ if file_object is not None and file_object.filename == filename:
488+ return file_object
489+
490+ raise NotFoundError(filename)
491+
492 def verifySuccessfulUpload(self):
493 """See `IPackageBuild`."""
494 # We have no interesting checks to perform here.
495diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
496index 6118379..91b24a2 100644
497--- a/lib/lp/code/model/tests/test_cibuild.py
498+++ b/lib/lp/code/model/tests/test_cibuild.py
499@@ -23,6 +23,7 @@ from zope.component import getUtility
500 from zope.security.proxy import removeSecurityProxy
501
502 from lp.app.enums import InformationType
503+from lp.app.errors import NotFoundError
504 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
505 from lp.buildmaster.enums import (
506 BuildQueueStatus,
507@@ -244,6 +245,17 @@ class TestCIBuild(TestCaseWithFactory):
508 build = self.factory.makeCIBuild()
509 self.assertEqual('CIBUILD-%d' % build.id, build.build_cookie)
510
511+ def test_getFileByName_logs(self):
512+ # getFileByName returns the logs when requested by name.
513+ build = self.factory.makeCIBuild()
514+ build.setLog(
515+ self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz"))
516+ self.assertEqual(build.log, build.getFileByName("buildlog.txt.gz"))
517+ self.assertRaises(NotFoundError, build.getFileByName, "foo")
518+ build.storeUploadLog("uploaded")
519+ self.assertEqual(
520+ build.upload_log, build.getFileByName(build.upload_log.filename))
521+
522 def addFakeBuildLog(self, build):
523 build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
524
525diff --git a/lib/lp/code/templates/cibuild-index.pt b/lib/lp/code/templates/cibuild-index.pt
526new file mode 100644
527index 0000000..35ef0cf
528--- /dev/null
529+++ b/lib/lp/code/templates/cibuild-index.pt
530@@ -0,0 +1,181 @@
531+<html
532+ xmlns="http://www.w3.org/1999/xhtml"
533+ xmlns:tal="http://xml.zope.org/namespaces/tal"
534+ xmlns:metal="http://xml.zope.org/namespaces/metal"
535+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
536+ metal:use-macro="view/macro:page/main_only"
537+ i18n:domain="launchpad"
538+>
539+
540+ <body>
541+
542+ <tal:registering metal:fill-slot="registering">
543+ created
544+ <span tal:content="context/date_created/fmt:displaydate"
545+ tal:attributes="title context/date_created/fmt:datetime"/>
546+ </tal:registering>
547+
548+ <div metal:fill-slot="main">
549+
550+ <div class="yui-g">
551+
552+ <div id="status" class="yui-u first">
553+ <div class="portlet">
554+ <div metal:use-macro="template/macros/status"/>
555+ </div>
556+ </div>
557+
558+ <div id="details" class="yui-u">
559+ <div class="portlet">
560+ <div metal:use-macro="template/macros/details"/>
561+ </div>
562+ </div>
563+
564+ </div> <!-- yui-g -->
565+
566+ <div id="buildlog" class="portlet"
567+ tal:condition="context/status/enumvalue:BUILDING">
568+ <div metal:use-macro="template/macros/buildlog"/>
569+ </div>
570+
571+ </div> <!-- main -->
572+
573+
574+<metal:macros fill-slot="bogus">
575+
576+ <metal:macro define-macro="details">
577+ <tal:comment replace="nothing">
578+ Details section.
579+ </tal:comment>
580+ <h2>Build details</h2>
581+ <div class="two-column-list">
582+ <dl>
583+ <dt>Source:</dt>
584+ <dd>
585+ <tal:repository
586+ replace="structure context/git_repository/fmt:link"/>
587+ </dd>
588+ </dl>
589+ <dl>
590+ <dt>Commit:</dt>
591+ <dd>
592+ <tal:commit replace="context/commit_sha1"/>
593+ </dd>
594+ </dl>
595+ <dl>
596+ <dt>Series:</dt>
597+ <dd><a class="sprite distribution"
598+ tal:define="series context/distro_series"
599+ tal:attributes="href series/fmt:url"
600+ tal:content="series/displayname"/>
601+ </dd>
602+ </dl>
603+ <dl>
604+ <dt>Architecture:</dt>
605+ <dd><a class="sprite distribution"
606+ tal:define="archseries context/distro_arch_series"
607+ tal:attributes="href archseries/fmt:url"
608+ tal:content="archseries/architecturetag"/>
609+ </dd>
610+ </dl>
611+ </div>
612+ </metal:macro>
613+
614+ <metal:macro define-macro="status">
615+ <tal:comment replace="nothing">
616+ Status section.
617+ </tal:comment>
618+ <h2>Build status</h2>
619+ <p>
620+ <span tal:replace="structure context/image:icon" />
621+ <span tal:attributes="
622+ class string:buildstatus${context/status/name};"
623+ tal:content="context/status/title"/>
624+ <tal:builder condition="context/builder">
625+ on <a tal:content="context/builder/title"
626+ tal:attributes="href context/builder/fmt:url"/>
627+ </tal:builder>
628+ <tal:retry define="link context/menu:context/retry"
629+ condition="link/enabled"
630+ replace="structure link/fmt:link" />
631+ <tal:cancel define="link context/menu:context/cancel"
632+ condition="link/enabled"
633+ replace="structure link/fmt:link" />
634+ </p>
635+
636+ <ul>
637+ <li tal:condition="context/dependencies">
638+ Missing build dependencies: <em tal:content="context/dependencies"/>
639+ </li>
640+ <tal:reallypending condition="context/buildqueue_record">
641+ <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING">
642+ <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime">
643+ Start <tal:eta replace="eta/fmt:approximatedate"/>
644+ (<span tal:replace="context/buildqueue_record/lastscore"/>)
645+ <a href="https://help.launchpad.net/Packaging/BuildScores"
646+ target="_blank">What's this?</a>
647+ </li>
648+ </tal:pending>
649+ </tal:reallypending>
650+ <tal:started condition="context/date_started">
651+ <li tal:condition="context/date_started">
652+ Started <span
653+ tal:define="start context/date_started"
654+ tal:attributes="title start/fmt:datetime"
655+ tal:content="start/fmt:displaydate"/>
656+ </li>
657+ </tal:started>
658+ <tal:finish condition="not: context/date_finished">
659+ <li tal:define="eta context/eta" tal:condition="context/eta">
660+ Estimated finish <tal:eta replace="eta/fmt:approximatedate"/>
661+ </li>
662+ </tal:finish>
663+
664+ <li tal:condition="context/date_finished">
665+ Finished <span
666+ tal:attributes="title context/date_finished/fmt:datetime"
667+ tal:content="context/date_finished/fmt:displaydate"/>
668+ <tal:duration condition="context/duration">
669+ (took <span tal:replace="context/duration/fmt:exactduration"/>)
670+ </tal:duration>
671+ </li>
672+ <li tal:define="file context/log"
673+ tal:condition="file">
674+ <a class="sprite download"
675+ tal:attributes="href context/log_url">buildlog</a>
676+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
677+ </li>
678+ <li tal:define="file context/upload_log"
679+ tal:condition="file">
680+ <a class="sprite download"
681+ tal:attributes="href context/upload_log_url">uploadlog</a>
682+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
683+ </li>
684+ </ul>
685+
686+ <div
687+ style="margin-top: 1.5em"
688+ tal:define="link context/menu:context/rescore"
689+ tal:condition="link/enabled"
690+ >
691+ <a tal:replace="structure link/fmt:link"/>
692+ </div>
693+ </metal:macro>
694+
695+ <metal:macro define-macro="buildlog">
696+ <tal:comment replace="nothing">
697+ Buildlog section.
698+ </tal:comment>
699+ <h2>Buildlog</h2>
700+ <div id="buildlog-tail" class="logtail"
701+ tal:define="logtail context/buildqueue_record/logtail"
702+ tal:content="structure logtail/fmt:text-to-html"/>
703+ <p class="lesser" tal:condition="view/user">
704+ Updated on <span tal:replace="structure view/user/fmt:local-time"/>
705+ </p>
706+ </metal:macro>
707+
708+</metal:macros>
709+
710+ </body>
711+</html>
712diff --git a/lib/lp/code/templates/cibuild-retry.pt b/lib/lp/code/templates/cibuild-retry.pt
713new file mode 100644
714index 0000000..ba08004
715--- /dev/null
716+++ b/lib/lp/code/templates/cibuild-retry.pt
717@@ -0,0 +1,28 @@
718+<html
719+ xmlns="http://www.w3.org/1999/xhtml"
720+ xmlns:tal="http://xml.zope.org/namespaces/tal"
721+ xmlns:metal="http://xml.zope.org/namespaces/metal"
722+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
723+ metal:use-macro="view/macro:page/main_only"
724+ i18n:domain="launchpad">
725+<body>
726+
727+ <div metal:fill-slot="main">
728+ <div metal:use-macro="context/@@launchpad_form/form">
729+ <div metal:fill-slot="extra_info">
730+ <p>
731+ The status of <dfn tal:content="context/title" /> is
732+ <span tal:replace="context/status/title" />.
733+ </p>
734+ <p>Retrying this build will destroy its history and logs.</p>
735+ <p>
736+ By default, this build will be retried only after other pending
737+ builds; please contact a build daemon administrator if you need
738+ special treatment.
739+ </p>
740+ </div>
741+ </div>
742+ </div>
743+
744+</body>
745+</html>

Subscribers

People subscribed via source and target branches

to status/vote changes: