← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging ~cjwatson/launchpad:ci-build-basic-views into launchpad:master.

Commit message:
Add basic index/retry/cancel/rescore views for CI builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This should be just enough to let us deal with build farm administration as it relates to CI builds.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ci-build-basic-views into launchpad:master.
diff --git a/lib/lp/code/browser/cibuild.py b/lib/lp/code/browser/cibuild.py
new file mode 100644
index 0000000..e03d7b7
--- /dev/null
+++ b/lib/lp/code/browser/cibuild.py
@@ -0,0 +1,148 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""CI build views."""
+
+__all__ = [
+    "CIBuildContextMenu",
+    "CIBuildNavigation",
+    "CIBuildView",
+    ]
+
+from zope.interface import Interface
+
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadFormView,
+    )
+from lp.code.interfaces.cibuild import ICIBuild
+from lp.services.librarian.browser import FileNavigationMixin
+from lp.services.webapp import (
+    canonical_url,
+    ContextMenu,
+    enabled_with_permission,
+    Link,
+    Navigation,
+    )
+from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
+
+
+class CIBuildNavigation(Navigation, FileNavigationMixin):
+    usedfor = ICIBuild
+
+
+class CIBuildContextMenu(ContextMenu):
+    """Context menu for CI builds."""
+
+    usedfor = ICIBuild
+
+    facet = "overview"
+
+    links = ("retry", "cancel", "rescore")
+
+    @enabled_with_permission("launchpad.Edit")
+    def retry(self):
+        return Link(
+            "+retry", "Retry this build", icon="retry",
+            enabled=self.context.can_be_retried)
+
+    @enabled_with_permission("launchpad.Edit")
+    def cancel(self):
+        return Link(
+            "+cancel", "Cancel build", icon="remove",
+            enabled=self.context.can_be_cancelled)
+
+    @enabled_with_permission("launchpad.Admin")
+    def rescore(self):
+        return Link(
+            "+rescore", "Rescore build", icon="edit",
+            enabled=self.context.can_be_rescored)
+
+
+class CIBuildView(LaunchpadFormView):
+    """Default view of a CI build."""
+
+    class schema(Interface):
+        """Schema for a build."""
+
+    @property
+    def label(self):
+        return self.context.title
+
+    page_title = label
+
+
+class CIBuildRetryView(LaunchpadFormView):
+    """View for retrying a CI build."""
+
+    class schema(Interface):
+        """Schema for retrying a build."""
+
+    page_title = label = "Retry build"
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action("Retry build", name="retry")
+    def request_action(self, action, data):
+        """Retry the build."""
+        if not self.context.can_be_retried:
+            self.request.response.addErrorNotification(
+                "Build cannot be retried")
+        else:
+            self.context.retry()
+            self.request.response.addInfoNotification("Build has been queued")
+
+        self.request.response.redirect(self.next_url)
+
+
+class CIBuildCancelView(LaunchpadFormView):
+    """View for cancelling a CI build."""
+
+    class schema(Interface):
+        """Schema for cancelling a build."""
+
+    page_title = label = "Cancel build"
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action("Cancel build", name="cancel")
+    def request_action(self, action, data):
+        """Cancel the build."""
+        self.context.cancel()
+
+
+class CIBuildRescoreView(LaunchpadFormView):
+    """View for rescoring a CI build."""
+
+    schema = IBuildRescoreForm
+
+    page_title = label = "Rescore build"
+
+    def __call__(self):
+        if self.context.can_be_rescored:
+            return super().__call__()
+        self.request.response.addWarningNotification(
+            "Cannot rescore this build because it is not queued.")
+        self.request.response.redirect(canonical_url(self.context))
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action("Rescore build", name="rescore")
+    def request_action(self, action, data):
+        """Rescore the build."""
+        score = data.get("priority")
+        self.context.rescore(score)
+        self.request.response.addNotification("Build rescored to %s." % score)
+
+    @property
+    def initial_values(self):
+        return {"score": str(self.context.buildqueue_record.lastscore)}
diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
index 5520698..3f16bf4 100644
--- a/lib/lp/code/browser/configure.zcml
+++ b/lib/lp/code/browser/configure.zcml
@@ -1422,6 +1422,44 @@
             for="lp.code.interfaces.cibuild.ICIBuild"
             path_expression="string:+build/${id}"
             attribute_to_parent="git_repository"/>
+        <browser:menus
+            module="lp.code.browser.cibuild"
+            classes="CIBuildContextMenu" />
+        <browser:navigation
+            module="lp.code.browser.cibuild"
+            classes="CIBuildNavigation" />
+        <browser:defaultView
+            for="lp.code.interfaces.cibuild.ICIBuild"
+            name="+index" />
+        <browser:page
+            for="lp.code.interfaces.cibuild.ICIBuild"
+            class="lp.code.browser.cibuild.CIBuildView"
+            permission="launchpad.View"
+            name="+index"
+            template="../templates/cibuild-index.pt" />
+        <browser:page
+            for="lp.code.interfaces.cibuild.ICIBuild"
+            class="lp.code.browser.cibuild.CIBuildRetryView"
+            permission="launchpad.Edit"
+            name="+retry"
+            template="../templates/cibuild-retry.pt" />
+        <browser:page
+            for="lp.code.interfaces.cibuild.ICIBuild"
+            class="lp.code.browser.cibuild.CIBuildCancelView"
+            permission="launchpad.Edit"
+            name="+cancel"
+            template="../../app/templates/generic-edit.pt" />
+        <browser:page
+            for="lp.code.interfaces.cibuild.ICIBuild"
+            class="lp.code.browser.cibuild.CIBuildRescoreView"
+            permission="launchpad.Admin"
+            name="+rescore"
+            template="../../app/templates/generic-edit.pt" />
+        <adapter
+            provides="lp.services.webapp.interfaces.IBreadcrumb"
+            for="lp.code.interfaces.cibuild.ICIBuild"
+            factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
+            permission="zope.Public" />
 
     </facet>
 
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index f0b6668..072f626 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -99,6 +99,7 @@ from lp.code.errors import (
     GitRepositoryExists,
     GitTargetError,
     )
+from lp.code.interfaces.cibuild import ICIBuildSet
 from lp.code.interfaces.gitnamespace import get_git_namespace
 from lp.code.interfaces.gitref import IGitRefBatchNavigator
 from lp.code.interfaces.gitrepository import (
@@ -145,6 +146,7 @@ from lp.services.webapp.publisher import DataDownloadView
 from lp.services.webapp.snapshot import notify_modified
 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.snappy.browser.hassnaps import HasSnapsViewMixin
+from lp.soyuz.browser.build import get_build_by_id_str
 
 
 GIT_REPOSITORY_FORK_ENABLED = 'gitrepository.fork.enabled'
@@ -270,6 +272,13 @@ class GitRepositoryNavigation(WebhookTargetNavigationMixin, Navigation):
         """Traverses to the `ICodeImport` for the repository."""
         return self.context.code_import
 
+    @stepthrough("+build")
+    def traverse_build(self, name):
+        build = get_build_by_id_str(ICIBuildSet, name)
+        if build is None or build.git_repository != self.context:
+            return None
+        return build
+
 
 class GitRepositoryEditMenu(NavigationMenu):
     """Edit menu for `IGitRepository`."""
diff --git a/lib/lp/code/browser/tests/test_cibuild.py b/lib/lp/code/browser/tests/test_cibuild.py
new file mode 100644
index 0000000..662b830
--- /dev/null
+++ b/lib/lp/code/browser/tests/test_cibuild.py
@@ -0,0 +1,202 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test CI build views."""
+
+import re
+
+from fixtures import FakeLogger
+import soupmatchers
+from storm.locals import Store
+import transaction
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+from zope.testbrowser.browser import LinkNotFoundError
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.services.webapp import canonical_url
+from lp.testing import (
+    ANONYMOUS,
+    BrowserTestCase,
+    login,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    )
+
+
+class TestCanonicalUrlForCIBuild(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_canonical_url(self):
+        repository = self.factory.makeGitRepository()
+        build = self.factory.makeCIBuild(git_repository=repository)
+        self.assertEqual(
+            "http://launchpad.test/%s/+build/%s"; % (
+                repository.shortened_path, build.id),
+            canonical_url(build))
+
+
+class TestCIBuildOperations(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FakeLogger())
+        self.build = self.factory.makeCIBuild()
+        self.build_url = canonical_url(self.build)
+        self.repository = self.build.git_repository
+        self.buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+
+    def test_retry_build(self):
+        # The owner of a build's repository can retry it.
+        self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.repository.owner)
+        browser.getLink("Retry this build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Retry build").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(BuildStatus.NEEDSBUILD, self.build.status)
+
+    def test_retry_build_random_user(self):
+        # An unrelated non-admin user cannot retry a build.
+        self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+        transaction.commit()
+        user = self.factory.makePerson()
+        browser = self.getViewBrowser(self.build, user=user)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Retry this build")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, self.build_url + "/+retry",
+            user=user)
+
+    def test_retry_build_wrong_state(self):
+        # If the build isn't in an unsuccessful terminal state, you can't
+        # retry it.
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        browser = self.getViewBrowser(self.build, user=self.repository.owner)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Retry this build")
+
+    def test_cancel_build(self):
+        # The owner of a build's repository can cancel it.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.repository.owner)
+        browser.getLink("Cancel build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Cancel build").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+
+    def test_cancel_build_random_user(self):
+        # An unrelated non-admin user cannot cancel a build.
+        self.build.queueBuild()
+        transaction.commit()
+        user = self.factory.makePerson()
+        browser = self.getViewBrowser(self.build, user=user)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, self.build_url + "/+cancel",
+            user=user)
+
+    def test_cancel_build_wrong_state(self):
+        # If the build isn't queued, you can't cancel it.
+        browser = self.getViewBrowser(self.build, user=self.repository.owner)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+
+    def test_rescore_build(self):
+        # A buildd admin can rescore a build.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        browser.getLink("Rescore build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Priority").value = "1024"
+        browser.getControl("Rescore build").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(1024, self.build.buildqueue_record.lastscore)
+
+    def test_rescore_build_invalid_score(self):
+        # Build scores can only take numbers.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        browser.getLink("Rescore build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Priority").value = "tentwentyfour"
+        browser.getControl("Rescore build").click()
+        self.assertEqual(
+            "Invalid integer data",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+    def test_rescore_build_not_admin(self):
+        # A non-admin user cannot cancel a build.
+        self.build.queueBuild()
+        transaction.commit()
+        user = self.factory.makePerson()
+        browser = self.getViewBrowser(self.build, user=user)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, self.build_url + "/+rescore",
+            user=user)
+
+    def test_rescore_build_wrong_state(self):
+        # If the build isn't NEEDSBUILD, you can't rescore it.
+        self.build.queueBuild()
+        with person_logged_in(self.repository.owner):
+            self.build.cancel()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+
+    def test_rescore_build_wrong_state_stale_link(self):
+        # An attempt to rescore a non-queued build from a stale link shows a
+        # sensible error message.
+        self.build.queueBuild()
+        with person_logged_in(self.repository.owner):
+            self.build.cancel()
+        browser = self.getViewBrowser(
+            self.build, "+rescore", user=self.buildd_admin)
+        self.assertEqual(self.build_url, browser.url)
+        self.assertThat(browser.contents, soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "notification", "div", attrs={"class": "warning message"},
+                text="Cannot rescore this build because it is not queued.")))
+
+    def test_builder_history(self):
+        Store.of(self.build).flush()
+        self.build.updateStatus(
+            BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder())
+        title = self.build.title
+        browser = self.getViewBrowser(self.build.builder, "+history")
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            r"Build history.*%s" % re.escape(title),
+            extract_text(find_main_content(browser.contents)))
+        self.assertEqual(self.build_url, browser.getLink(title).url)
+
+    def makeBuildingRecipe(self):
+        builder = self.factory.makeBuilder()
+        build = self.factory.makeCIBuild()
+        build.updateStatus(BuildStatus.BUILDING, builder=builder)
+        build.queueBuild()
+        build.buildqueue_record.builder = builder
+        build.buildqueue_record.logtail = "tail of the log"
+        return build
+
+    def test_builder_index_public(self):
+        build = self.makeBuildingRecipe()
+        browser = self.getViewBrowser(build.builder, no_login=True)
+        self.assertIn("tail of the log", browser.contents)
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index e1abcf8..470cd95 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -130,6 +130,19 @@ class ICIBuildView(IPackageBuildView):
             cannot be parsed.
         """
 
+    def getFileByName(filename):
+        """Return the corresponding `ILibraryFileAlias` in this context.
+
+        The following file types (and extension) can be looked up:
+
+         * Build log: '.txt.gz'
+         * Upload log: '_log.txt'
+
+        :param filename: The filename to look up.
+        :raises NotFoundError: if no file exists with the given name.
+        :return: The corresponding `ILibraryFileAlias`.
+        """
+
 
 class ICIBuildEdit(IBuildFarmJobEdit):
     """`ICIBuild` methods that require launchpad.Edit."""
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index 4da34fb..785b1cc 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -359,6 +359,20 @@ class CIBuild(PackageBuildMixin, StormBase):
                 "%s: %s" % (msg % self.git_repository.unique_name, e))
         return parse_configuration(self.git_repository, blob)
 
+    def getFileByName(self, filename):
+        """See `ICIBuild`."""
+        if filename.endswith(".txt.gz"):
+            file_object = self.log
+        elif filename.endswith("_log.txt"):
+            file_object = self.upload_log
+        else:
+            file_object = None
+
+        if file_object is not None and file_object.filename == filename:
+            return file_object
+
+        raise NotFoundError(filename)
+
     def verifySuccessfulUpload(self):
         """See `IPackageBuild`."""
         # We have no interesting checks to perform here.
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index 6118379..91b24a2 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -23,6 +23,7 @@ from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
+from lp.app.errors import NotFoundError
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import (
     BuildQueueStatus,
@@ -244,6 +245,17 @@ class TestCIBuild(TestCaseWithFactory):
         build = self.factory.makeCIBuild()
         self.assertEqual('CIBUILD-%d' % build.id, build.build_cookie)
 
+    def test_getFileByName_logs(self):
+        # getFileByName returns the logs when requested by name.
+        build = self.factory.makeCIBuild()
+        build.setLog(
+            self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz"))
+        self.assertEqual(build.log, build.getFileByName("buildlog.txt.gz"))
+        self.assertRaises(NotFoundError, build.getFileByName, "foo")
+        build.storeUploadLog("uploaded")
+        self.assertEqual(
+            build.upload_log, build.getFileByName(build.upload_log.filename))
+
     def addFakeBuildLog(self, build):
         build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
 
diff --git a/lib/lp/code/templates/cibuild-index.pt b/lib/lp/code/templates/cibuild-index.pt
new file mode 100644
index 0000000..35ef0cf
--- /dev/null
+++ b/lib/lp/code/templates/cibuild-index.pt
@@ -0,0 +1,181 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+>
+
+  <body>
+
+    <tal:registering metal:fill-slot="registering">
+        created
+        <span tal:content="context/date_created/fmt:displaydate"
+              tal:attributes="title context/date_created/fmt:datetime"/>
+    </tal:registering>
+
+    <div metal:fill-slot="main">
+
+      <div class="yui-g">
+
+        <div id="status" class="yui-u first">
+          <div class="portlet">
+            <div metal:use-macro="template/macros/status"/>
+          </div>
+        </div>
+
+        <div id="details" class="yui-u">
+          <div class="portlet">
+            <div metal:use-macro="template/macros/details"/>
+          </div>
+        </div>
+
+      </div> <!-- yui-g -->
+
+      <div id="buildlog" class="portlet"
+           tal:condition="context/status/enumvalue:BUILDING">
+        <div metal:use-macro="template/macros/buildlog"/>
+      </div>
+
+   </div> <!-- main -->
+
+
+<metal:macros fill-slot="bogus">
+
+  <metal:macro define-macro="details">
+    <tal:comment replace="nothing">
+      Details section.
+    </tal:comment>
+    <h2>Build details</h2>
+    <div class="two-column-list">
+      <dl>
+        <dt>Source:</dt>
+        <dd>
+          <tal:repository
+              replace="structure context/git_repository/fmt:link"/>
+        </dd>
+      </dl>
+      <dl>
+        <dt>Commit:</dt>
+        <dd>
+          <tal:commit replace="context/commit_sha1"/>
+        </dd>
+      </dl>
+      <dl>
+        <dt>Series:</dt>
+        <dd><a class="sprite distribution"
+               tal:define="series context/distro_series"
+               tal:attributes="href series/fmt:url"
+               tal:content="series/displayname"/>
+        </dd>
+      </dl>
+      <dl>
+        <dt>Architecture:</dt>
+        <dd><a class="sprite distribution"
+               tal:define="archseries context/distro_arch_series"
+               tal:attributes="href archseries/fmt:url"
+               tal:content="archseries/architecturetag"/>
+        </dd>
+      </dl>
+    </div>
+  </metal:macro>
+
+  <metal:macro define-macro="status">
+    <tal:comment replace="nothing">
+      Status section.
+    </tal:comment>
+    <h2>Build status</h2>
+    <p>
+      <span tal:replace="structure context/image:icon" />
+      <span tal:attributes="
+            class string:buildstatus${context/status/name};"
+            tal:content="context/status/title"/>
+      <tal:builder condition="context/builder">
+        on <a tal:content="context/builder/title"
+              tal:attributes="href context/builder/fmt:url"/>
+      </tal:builder>
+      <tal:retry define="link context/menu:context/retry"
+                 condition="link/enabled"
+                 replace="structure link/fmt:link" />
+      <tal:cancel define="link context/menu:context/cancel"
+                  condition="link/enabled"
+                  replace="structure link/fmt:link" />
+    </p>
+
+    <ul>
+      <li tal:condition="context/dependencies">
+        Missing build dependencies: <em tal:content="context/dependencies"/>
+     </li>
+      <tal:reallypending condition="context/buildqueue_record">
+      <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING">
+        <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime">
+          Start <tal:eta replace="eta/fmt:approximatedate"/>
+          (<span tal:replace="context/buildqueue_record/lastscore"/>)
+          <a href="https://help.launchpad.net/Packaging/BuildScores";
+             target="_blank">What's this?</a>
+        </li>
+      </tal:pending>
+      </tal:reallypending>
+      <tal:started condition="context/date_started">
+        <li tal:condition="context/date_started">
+          Started <span
+           tal:define="start context/date_started"
+           tal:attributes="title start/fmt:datetime"
+           tal:content="start/fmt:displaydate"/>
+        </li>
+      </tal:started>
+      <tal:finish condition="not: context/date_finished">
+        <li tal:define="eta context/eta" tal:condition="context/eta">
+          Estimated finish <tal:eta replace="eta/fmt:approximatedate"/>
+        </li>
+      </tal:finish>
+
+      <li tal:condition="context/date_finished">
+        Finished <span
+          tal:attributes="title context/date_finished/fmt:datetime"
+          tal:content="context/date_finished/fmt:displaydate"/>
+        <tal:duration condition="context/duration">
+          (took <span tal:replace="context/duration/fmt:exactduration"/>)
+        </tal:duration>
+      </li>
+      <li tal:define="file context/log"
+          tal:condition="file">
+        <a class="sprite download"
+           tal:attributes="href context/log_url">buildlog</a>
+        (<span tal:replace="file/content/filesize/fmt:bytes" />)
+      </li>
+      <li tal:define="file context/upload_log"
+          tal:condition="file">
+        <a class="sprite download"
+           tal:attributes="href context/upload_log_url">uploadlog</a>
+        (<span tal:replace="file/content/filesize/fmt:bytes" />)
+      </li>
+    </ul>
+
+    <div
+      style="margin-top: 1.5em"
+      tal:define="link context/menu:context/rescore"
+      tal:condition="link/enabled"
+      >
+      <a tal:replace="structure link/fmt:link"/>
+    </div>
+  </metal:macro>
+
+  <metal:macro define-macro="buildlog">
+    <tal:comment replace="nothing">
+      Buildlog section.
+    </tal:comment>
+    <h2>Buildlog</h2>
+    <div id="buildlog-tail" class="logtail"
+         tal:define="logtail context/buildqueue_record/logtail"
+         tal:content="structure logtail/fmt:text-to-html"/>
+    <p class="lesser" tal:condition="view/user">
+      Updated on <span tal:replace="structure view/user/fmt:local-time"/>
+    </p>
+  </metal:macro>
+
+</metal:macros>
+
+  </body>
+</html>
diff --git a/lib/lp/code/templates/cibuild-retry.pt b/lib/lp/code/templates/cibuild-retry.pt
new file mode 100644
index 0000000..ba08004
--- /dev/null
+++ b/lib/lp/code/templates/cibuild-retry.pt
@@ -0,0 +1,28 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+<body>
+
+  <div metal:fill-slot="main">
+    <div metal:use-macro="context/@@launchpad_form/form">
+      <div metal:fill-slot="extra_info">
+        <p>
+          The status of <dfn tal:content="context/title" /> is
+          <span tal:replace="context/status/title" />.
+        </p>
+        <p>Retrying this build will destroy its history and logs.</p>
+        <p>
+          By default, this build will be retried only after other pending
+          builds; please contact a build daemon administrator if you need
+          special treatment.
+        </p>
+      </div>
+    </div>
+  </div>
+
+</body>
+</html>