launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27186
[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>