← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-recipe-build-basic-browser into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-build-basic-browser into launchpad:master with ~cjwatson/launchpad:charm-recipe-basic-browser as a prerequisite.

Commit message:
Add basic charm recipe build views

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403791
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-build-basic-browser into launchpad:master.
diff --git a/lib/lp/app/browser/configure.zcml b/lib/lp/app/browser/configure.zcml
index b08d380..005968c 100644
--- a/lib/lp/app/browser/configure.zcml
+++ b/lib/lp/app/browser/configure.zcml
@@ -877,6 +877,12 @@
       name="fmt"
       />
   <adapter
+      for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.CharmRecipeFormatterAPI"
+      name="fmt"
+      />
+  <adapter
       for="lp.blueprints.interfaces.specification.ISpecification"
       provides="zope.traversing.interfaces.IPathAdapter"
       factory="lp.app.browser.tales.SpecificationFormatterAPI"
diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py
index 17fd33f..1bf84aa 100644
--- a/lib/lp/app/browser/tales.py
+++ b/lib/lp/app/browser/tales.py
@@ -1951,6 +1951,18 @@ class SnappySeriesFormatterAPI(CustomizableFormatter):
         return {'title': self._context.title}
 
 
+class CharmRecipeFormatterAPI(CustomizableFormatter):
+    """Adapter providing fmt support for ICharmRecipe objects."""
+
+    _link_summary_template = _(
+        'Charm recipe %(name)s for %(owner)s in %(project)s')
+
+    def _link_summary_values(self):
+        return {'name': self._context.name,
+                'owner': self._context.owner.displayname,
+                'project': self._context.project.displayname}
+
+
 class SpecificationFormatterAPI(CustomizableFormatter):
     """Adapter providing fmt support for Specification objects"""
 
diff --git a/lib/lp/charms/browser/charmrecipebuild.py b/lib/lp/charms/browser/charmrecipebuild.py
new file mode 100644
index 0000000..f695084
--- /dev/null
+++ b/lib/lp/charms/browser/charmrecipebuild.py
@@ -0,0 +1,171 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe build views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "CharmRecipeBuildContextMenu",
+    "CharmRecipeBuildNavigation",
+    "CharmRecipeBuildView",
+    ]
+
+from zope.interface import Interface
+
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadFormView,
+    )
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+from lp.services.librarian.browser import (
+    FileNavigationMixin,
+    ProxiedLibraryFileAlias,
+    )
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp import (
+    canonical_url,
+    ContextMenu,
+    enabled_with_permission,
+    LaunchpadView,
+    Link,
+    Navigation,
+    )
+from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
+
+
+class CharmRecipeBuildNavigation(Navigation, FileNavigationMixin):
+    usedfor = ICharmRecipeBuild
+
+
+class CharmRecipeBuildContextMenu(ContextMenu):
+    """Context menu for charm recipe builds."""
+
+    usedfor = ICharmRecipeBuild
+
+    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 CharmRecipeBuildView(LaunchpadView):
+    """Default view of a charm recipe build."""
+
+    @property
+    def label(self):
+        return self.context.title
+
+    page_title = label
+
+    @cachedproperty
+    def files(self):
+        """Return `LibraryFileAlias`es for files produced by this build."""
+        if not self.context.was_built:
+            return None
+
+        return [
+            ProxiedLibraryFileAlias(alias, self.context)
+            for _, alias, _ in self.context.getFiles() if not alias.deleted]
+
+    @cachedproperty
+    def has_files(self):
+        return bool(self.files)
+
+    @property
+    def next_url(self):
+        return canonical_url(self.context)
+
+
+class CharmRecipeBuildRetryView(LaunchpadFormView):
+    """View for retrying a charm recipe 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 CharmRecipeBuildCancelView(LaunchpadFormView):
+    """View for cancelling a charm recipe 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 CharmRecipeBuildRescoreView(LaunchpadFormView):
+    """View for rescoring a charm recipe build."""
+
+    schema = IBuildRescoreForm
+
+    page_title = label = "Rescore build"
+
+    def __call__(self):
+        if self.context.can_be_rescored:
+            return super(CharmRecipeBuildRescoreView, self).__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/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 92d78bf..0475288 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -28,13 +28,53 @@
             for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
             factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb"
             permission="zope.Public" />
+
         <browser:url
             for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
             path_expression="string:+build-request/${id}"
             attribute_to_parent="recipe" />
+
         <browser:url
             for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
             path_expression="string:+build/${id}"
             attribute_to_parent="recipe" />
+        <browser:menus
+            module="lp.charms.browser.charmrecipebuild"
+            classes="CharmRecipeBuildContextMenu" />
+        <browser:navigation
+            module="lp.charms.browser.charmrecipebuild"
+            classes="CharmRecipeBuildNavigation" />
+        <browser:defaultView
+            for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
+            name="+index" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
+            class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildView"
+            permission="launchpad.View"
+            name="+index"
+            template="../templates/charmrecipebuild-index.pt" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
+            class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildRetryView"
+            permission="launchpad.Edit"
+            name="+retry"
+            template="../templates/charmrecipebuild-retry.pt" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
+            class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildCancelView"
+            permission="launchpad.Edit"
+            name="+cancel"
+            template="../../app/templates/generic-edit.pt" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
+            class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildRescoreView"
+            permission="launchpad.Admin"
+            name="+rescore"
+            template="../../app/templates/generic-edit.pt" />
+        <adapter
+            provides="lp.services.webapp.interfaces.IBreadcrumb"
+            for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
+            factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
+            permission="zope.Public" />
     </facet>
 </configure>
diff --git a/lib/lp/charms/browser/tests/test_charmrecipebuild.py b/lib/lp/charms/browser/tests/test_charmrecipebuild.py
new file mode 100644
index 0000000..0978a0a
--- /dev/null
+++ b/lib/lp/charms/browser/tests/test_charmrecipebuild.py
@@ -0,0 +1,264 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test charm recipe build views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import re
+
+from fixtures import FakeLogger
+import soupmatchers
+from storm.locals import Store
+from testtools.matchers import StartsWith
+import transaction
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
+    )
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp import canonical_url
+from lp.testing import (
+    ANONYMOUS,
+    BrowserTestCase,
+    login,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    )
+from lp.testing.views import create_initialized_view
+
+
+class TestCanonicalUrlForCharmRecipeBuild(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestCanonicalUrlForCharmRecipeBuild, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        project = self.factory.makeProduct(name="charm-project")
+        recipe = self.factory.makeCharmRecipe(
+            registrant=owner, owner=owner, project=project, name="charm")
+        build = self.factory.makeCharmRecipeBuild(
+            requester=owner, recipe=recipe)
+        self.assertThat(
+            canonical_url(build),
+            StartsWith(
+                "http://launchpad.test/~person/charm-project/+charm/charm/";
+                "+build/"))
+
+
+class TestCharmRecipeBuildView(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestCharmRecipeBuildView, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_files(self):
+        # CharmRecipeBuildView.files returns all the associated files.
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        charm_file = self.factory.makeCharmFile(build=build)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual(
+            [charm_file.library_file.filename],
+            [lfa.filename for lfa in build_view.files])
+        # Deleted files won't be included.
+        self.assertFalse(charm_file.library_file.deleted)
+        removeSecurityProxy(charm_file.library_file).content = None
+        self.assertTrue(charm_file.library_file.deleted)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual([], build_view.files)
+
+    def test_revision_id(self):
+        build = self.factory.makeCharmRecipeBuild()
+        build.updateStatus(
+            BuildStatus.FULLYBUILT, slave_status={"revision_id": "dummy"})
+        build_view = create_initialized_view(build, "+index")
+        self.assertThat(build_view(), soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "revision ID", "li", attrs={"id": "revision-id"},
+                text=re.compile(r"^\s*Revision: dummy\s*$"))))
+
+
+class TestCharmRecipeBuildOperations(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestCharmRecipeBuildOperations, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        self.useFixture(FakeLogger())
+        self.build = self.factory.makeCharmRecipeBuild()
+        self.build_url = canonical_url(self.build)
+        self.requester = self.build.requester
+        self.buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+
+    def test_retry_build(self):
+        # The requester of a build can retry it.
+        self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        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.requester)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Retry this build")
+
+    def test_cancel_build(self):
+        # The requester of a build can cancel it.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        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.requester)
+        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.requester):
+            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.requester):
+            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, information_type=InformationType.PUBLIC):
+        builder = self.factory.makeBuilder()
+        build = self.factory.makeCharmRecipeBuild(
+            information_type=information_type)
+        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/charms/templates/charmrecipebuild-index.pt b/lib/lp/charms/templates/charmrecipebuild-index.pt
new file mode 100644
index 0000000..1d0e4f0
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipebuild-index.pt
@@ -0,0 +1,201 @@
+<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="files" class="portlet" tal:condition="view/has_files">
+        <div metal:use-macro="template/macros/files"/>
+      </div>
+
+      <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>Recipe:</dt>
+          <dd>
+            <tal:recipe replace="structure context/recipe/fmt:link"/>
+          </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:building condition="context/status/enumvalue:BUILDING">
+        on <a tal:content="context/buildqueue_record/builder/title"
+              tal:attributes="href context/buildqueue_record/builder/fmt:url"/>
+      </tal:building>
+      <tal:built condition="context/builder">
+        on <a tal:content="context/builder/title"
+              tal:attributes="href context/builder/fmt:url"/>
+      </tal:built>
+      <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 id="revision-id" tal:condition="context/revision_id">
+        Revision: <span tal:replace="context/revision_id" />
+      </li>
+      <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="files">
+    <tal:comment replace="nothing">
+      Files section.
+    </tal:comment>
+    <h2>Built files</h2>
+    <p>Files resulting from this build:</p>
+    <ul>
+      <li tal:repeat="file view/files">
+        <a class="sprite download"
+           tal:content="file/filename"
+           tal:attributes="href file/http_url"/>
+        (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+      </li>
+    </ul>
+  </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/charms/templates/charmrecipebuild-retry.pt b/lib/lp/charms/templates/charmrecipebuild-retry.pt
new file mode 100644
index 0000000..ba08004
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipebuild-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>