← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:oci-recipe-basic-views into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-basic-views into launchpad:master with ~cjwatson/launchpad:oci-recipe-model-bugs as a prerequisite.

Commit message:
Add basic OCI recipe views

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

I've omitted several pieces (including most of the substantive tests of the OCIRecipeBuild views) until we have more of OCIRecipeBuild in place, but this at least allows creating, editing, and deleting OCI recipes as well as viewing any existing builds.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-basic-views into launchpad:master.
diff --git a/lib/lp/oci/browser/__init__.py b/lib/lp/oci/browser/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/browser/__init__.py
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
new file mode 100644
index 0000000..e3fcc08
--- /dev/null
+++ b/lib/lp/oci/browser/configure.zcml
@@ -0,0 +1,82 @@
+<!-- Copyright 2020 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure
+    xmlns="http://namespaces.zope.org/zope";
+    xmlns:browser="http://namespaces.zope.org/browser";
+    xmlns:i18n="http://namespaces.zope.org/i18n";
+    i18n_domain="launchpad">
+    <facet facet="overview">
+        <browser:url
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            path_expression="string:${oci_project/pillar/name}/+oci/${oci_project/name}/+recipe/${name}"
+            attribute_to_parent="owner" />
+        <browser:menus
+            module="lp.oci.browser.ocirecipe"
+            classes="OCIRecipeNavigationMenu" />
+        <browser:navigation
+            module="lp.oci.browser.ocirecipe"
+            classes="OCIRecipeNavigation" />
+        <browser:defaultView
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            name="+index" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeView"
+            permission="launchpad.View"
+            name="+index"
+            template="../templates/ocirecipe-index.pt" />
+        <browser:page
+            for="lp.registry.interfaces.ociproject.IOCIProject"
+            class="lp.oci.browser.ocirecipe.OCIRecipeAddView"
+            permission="launchpad.AnyLegitimatePerson"
+            name="+new-recipe"
+            template="../templates/ocirecipe-new.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeAdminView"
+            permission="launchpad.Admin"
+            name="+admin"
+            template="../../app/templates/generic-edit.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeEditView"
+            permission="launchpad.Edit"
+            name="+edit"
+            template="../../app/templates/generic-edit.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeDeleteView"
+            permission="launchpad.Edit"
+            name="+delete"
+            template="../../app/templates/generic-edit.pt" />
+        <adapter
+            provides="lp.services.webapp.interfaces.IBreadcrumb"
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            factory="lp.oci.browser.ocirecipe.OCIRecipeBreadcrumb"
+            permission="zope.Public" />
+
+        <browser:url
+            for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
+            path_expression="string:+build/${id}"
+            attribute_to_parent="recipe" />
+        <browser:navigation
+            module="lp.oci.browser.ocirecipebuild"
+            classes="OCIRecipeBuildNavigation" />
+        <browser:defaultView
+            for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
+            name="+index" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
+            class="lp.oci.browser.ocirecipebuild.OCIRecipeBuildView"
+            permission="launchpad.View"
+            name="+index"
+            template="../templates/ocirecipebuild-index.pt" />
+        <adapter
+            provides="lp.services.webapp.interfaces.IBreadcrumb"
+            for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
+            factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
+            permission="zope.Public" />
+    </facet>
+</configure>
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
new file mode 100644
index 0000000..483eb85
--- /dev/null
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -0,0 +1,291 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCI recipe views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIRecipeAddView',
+    'OCIRecipeAdminView',
+    'OCIRecipeDeleteView',
+    'OCIRecipeEditView',
+    'OCIRecipeNavigation',
+    'OCIRecipeNavigationMenu',
+    'OCIRecipeView',
+    ]
+
+from lazr.restful.interface import (
+    copy_field,
+    use_template,
+    )
+from zope.component import getUtility
+from zope.interface import Interface
+
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadEditFormView,
+    LaunchpadFormView,
+    )
+from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
+from lp.app.browser.tales import format_link
+from lp.code.browser.widgets.gitref import GitRefWidget
+from lp.oci.interfaces.ocirecipe import (
+    IOCIRecipe,
+    IOCIRecipeSet,
+    NoSuchOCIRecipe,
+    )
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp import (
+    canonical_url,
+    enabled_with_permission,
+    LaunchpadView,
+    Link,
+    Navigation,
+    NavigationMenu,
+    stepthrough,
+    )
+from lp.services.webapp.breadcrumb import NameBreadcrumb
+from lp.soyuz.browser.build import get_build_by_id_str
+
+
+class OCIRecipeNavigation(Navigation):
+
+    usedfor = IOCIRecipe
+
+    @stepthrough('+build')
+    def traverse_build(self, name):
+        build = get_build_by_id_str(IOCIRecipeBuildSet, name)
+        if build is None or build.recipe != self.context:
+            return None
+        return build
+
+
+class OCIRecipeBreadcrumb(NameBreadcrumb):
+
+    @property
+    def inside(self):
+        return self.context.oci_project
+
+
+class OCIRecipeNavigationMenu(NavigationMenu):
+    """Navigation menu for OCI recipes."""
+
+    usedfor = IOCIRecipe
+
+    facet = "overview"
+
+    links = ("admin", "edit", "delete")
+
+    @enabled_with_permission("launchpad.Admin")
+    def admin(self):
+        return Link("+admin", "Administer OCI recipe", icon="edit")
+
+    @enabled_with_permission("launchpad.Edit")
+    def edit(self):
+        return Link("+edit", "Edit OCI recipe", icon="edit")
+
+    @enabled_with_permission("launchpad.Edit")
+    def delete(self):
+        return Link("+delete", "Delete OCI recipe", icon="trash-icon")
+
+
+class OCIRecipeView(LaunchpadView):
+    """Default view of an OCI recipe."""
+
+    @cachedproperty
+    def builds(self):
+        return builds_for_recipe(self.context)
+
+    @property
+    def person_picker(self):
+        field = copy_field(
+            IOCIRecipe["owner"],
+            vocabularyName="AllUserTeamsParticipationPlusSelfSimpleDisplay")
+        return InlinePersonEditPickerWidget(
+            self.context, field, format_link(self.context.owner),
+            header="Change owner", step_title="Select a new owner")
+
+    @property
+    def build_frequency(self):
+        if self.context.build_daily:
+            return "Built daily"
+        else:
+            return "Built on request"
+
+
+def builds_for_recipe(recipe):
+    """A list of interesting builds.
+
+    All pending builds are shown, as well as 1-10 recent builds.  Recent
+    builds are ordered by date finished (if completed) or date_started (if
+    date finished is not set due to an error building or other circumstance
+    which resulted in the build not being completed).  This allows started
+    but unfinished builds to show up in the view but be discarded as more
+    recent builds become available.
+
+    Builds that the user does not have permission to see are excluded (by
+    the model code).
+    """
+    builds = list(recipe.pending_builds)
+    if len(builds) < 10:
+        builds.extend(recipe.completed_builds[:10 - len(builds)])
+    return builds
+
+
+class IOCIRecipeEditSchema(Interface):
+    """Schema for adding or editing an OCI recipe."""
+
+    use_template(IOCIRecipe, include=[
+        "name",
+        "owner",
+        "description",
+        "git_ref",
+        "build_file",
+        "build_daily",
+        "require_virtualized",
+        ])
+
+
+class OCIRecipeAddView(LaunchpadFormView):
+    """View for creating OCI recipes."""
+
+    page_title = label = "Create a new OCI recipe"
+
+    schema = IOCIRecipeEditSchema
+    field_names = (
+        "name",
+        "owner",
+        "description",
+        "git_ref",
+        "build_file",
+        "build_daily",
+        )
+    custom_widget_git_ref = GitRefWidget
+
+    @property
+    def cancel_url(self):
+        """See `LaunchpadFormView`."""
+        return canonical_url(self.context)
+
+    @property
+    def initial_values(self):
+        """See `LaunchpadFormView`."""
+        return {
+            "owner": self.user,
+            "build_file": "Dockerfile",
+            }
+
+    def validate(self, data):
+        """See `LaunchpadFormView`."""
+        super(OCIRecipeAddView, self).validate(data)
+        owner = data.get("owner", None)
+        name = data.get("name", None)
+        if owner and name:
+            if getUtility(IOCIRecipeSet).exists(owner, self.context, name):
+                self.setFieldError(
+                    "name",
+                    "There is already an OCI recipe owned by %s in %s with "
+                    "this name." % (
+                        owner.display_name, self.context.display_name))
+
+    @action("Create OCI recipe", name="create")
+    def create_action(self, action, data):
+        recipe = getUtility(IOCIRecipeSet).new(
+            name=data["name"], registrant=self.user, owner=data["owner"],
+            oci_project=self.context, git_ref=data["git_ref"],
+            build_file=data["build_file"], description=data["description"])
+        self.next_url = canonical_url(recipe)
+
+
+class BaseOCIRecipeEditView(LaunchpadEditFormView):
+
+    schema = IOCIRecipeEditSchema
+
+    @property
+    def cancel_url(self):
+        """See `LaunchpadFormView`."""
+        return canonical_url(self.context)
+
+    @action("Update OCI recipe", name="update")
+    def request_action(self, action, data):
+        self.updateContextFromData(data)
+        self.next_url = canonical_url(self.context)
+
+    @property
+    def adapters(self):
+        """See `LaunchpadFormView`."""
+        return {IOCIRecipeEditSchema: self.context}
+
+
+class OCIRecipeAdminView(BaseOCIRecipeEditView):
+    """View for administering OCI recipes."""
+
+    @property
+    def label(self):
+        return "Administer %s OCI recipe" % self.context.name
+
+    page_title = "Administer"
+
+    field_names = ("require_virtualized",)
+
+
+class OCIRecipeEditView(BaseOCIRecipeEditView):
+    """View for editing OCI recipes."""
+
+    @property
+    def label(self):
+        return "Edit %s OCI recipe" % self.context.name
+
+    page_title = "Edit"
+
+    field_names = (
+        "owner",
+        "name",
+        "description",
+        "git_ref",
+        "build_file",
+        "build_daily",
+        )
+    custom_widget_git_ref = GitRefWidget
+
+    def validate(self, data):
+        """See `LaunchpadFormView`."""
+        super(OCIRecipeEditView, self).validate(data)
+        # XXX cjwatson 2020-02-18: We should permit and check moving recipes
+        # between OCI projects too.
+        owner = data.get("owner", None)
+        name = data.get("name", None)
+        if owner and name:
+            try:
+                recipe = getUtility(IOCIRecipeSet).getByName(
+                    owner, self.context.oci_project, name)
+                if recipe != self.context:
+                    self.setFieldError(
+                        "name",
+                        "There is already an OCI recipe owned by %s in %s "
+                        "with this name." % (
+                            owner.display_name,
+                            self.context.oci_project.display_name))
+            except NoSuchOCIRecipe:
+                pass
+
+
+class OCIRecipeDeleteView(BaseOCIRecipeEditView):
+    """View for deleting OCI recipes."""
+
+    @property
+    def label(self):
+        return "Delete %s OCI recipe" % self.context.name
+
+    page_title = "Delete"
+
+    field_names = ()
+
+    @action("Delete OCI recipe", name="delete")
+    def delete_action(self, action, data):
+        oci_project = self.context.oci_project
+        self.context.destroySelf()
+        self.next_url = canonical_url(oci_project)
diff --git a/lib/lp/oci/browser/ocirecipebuild.py b/lib/lp/oci/browser/ocirecipebuild.py
new file mode 100644
index 0000000..8b3a727
--- /dev/null
+++ b/lib/lp/oci/browser/ocirecipebuild.py
@@ -0,0 +1,43 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCI recipe build views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIRecipeBuildNavigation',
+    'OCIRecipeBuildView',
+    ]
+
+from zope.interface import Interface
+
+from lp.app.browser.launchpadform import LaunchpadFormView
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
+from lp.services.webapp import (
+    canonical_url,
+    Navigation,
+    )
+
+
+class OCIRecipeBuildNavigation(Navigation):
+
+    usedfor = IOCIRecipeBuild
+
+
+class OCIRecipeBuildView(LaunchpadFormView):
+    """Default view of an OCIRecipeBuild."""
+
+    class schema(Interface):
+        """Schema for uploading a build."""
+
+    @property
+    def label(self):
+        return self.context.title
+
+    page_title = label
+
+    @property
+    def next_url(self):
+        return canonical_url(self.context)
diff --git a/lib/lp/oci/browser/tests/__init__.py b/lib/lp/oci/browser/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/browser/tests/__init__.py
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
new file mode 100644
index 0000000..7933074
--- /dev/null
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -0,0 +1,454 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test OCI recipe views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+import re
+
+from fixtures import FakeLogger
+import pytz
+import soupmatchers
+from zope.component import getUtility
+from zope.publisher.interfaces import NotFound
+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.buildmaster.interfaces.processor import IProcessorSet
+from lp.oci.browser.ocirecipe import (
+    OCIRecipeAdminView,
+    OCIRecipeEditView,
+    OCIRecipeView,
+    )
+from lp.services.database.constants import UTC_NOW
+from lp.services.propertycache import get_property_cache
+from lp.services.webapp import canonical_url
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.testing import (
+    BrowserTestCase,
+    login,
+    login_person,
+    person_logged_in,
+    TestCaseWithFactory,
+    time_counter,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+from lp.testing.matchers import (
+    MatchesPickerText,
+    MatchesTagText,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    )
+from lp.testing.publication import test_traverse
+from lp.testing.views import create_view
+
+
+class TestOCIRecipeNavigation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        distribution = self.factory.makeDistribution(name="distro")
+        oci_project = self.factory.makeOCIProject(
+            pillar=distribution, ociprojectname="oci-project")
+        recipe = self.factory.makeOCIRecipe(
+            name="recipe", registrant=owner, owner=owner,
+            oci_project=oci_project)
+        self.assertEqual(
+            "http://launchpad.test/~person/distro/+oci/oci-project/";
+            "+recipe/recipe", canonical_url(recipe))
+
+    def test_recipe(self):
+        recipe = self.factory.makeOCIRecipe()
+        obj, _, _ = test_traverse(
+            "http://launchpad.test/~%s/%s/+oci/%s/+recipe/%s"; % (
+                recipe.owner.name, recipe.oci_project.pillar.name,
+                recipe.oci_project.name, recipe.name))
+        self.assertEqual(recipe, obj)
+
+
+class BaseTestOCIRecipeView(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(BaseTestOCIRecipeView, self).setUp()
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+
+class TestOCIRecipeAddView(BaseTestOCIRecipeView):
+
+    def test_create_new_recipe_not_logged_in(self):
+        oci_project = self.factory.makeOCIProject()
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser, oci_project,
+            view_name="+new-recipe", no_login=True)
+
+    def test_create_new_recipe(self):
+        oci_project = self.factory.makeOCIProject()
+        oci_project_display = oci_project.display_name
+        [git_ref] = self.factory.makeGitRefs()
+        source_display = git_ref.display_name
+        browser = self.getViewBrowser(
+            oci_project, view_name="+new-recipe", user=self.person)
+        browser.getControl(name="field.name").value = "recipe-name"
+        browser.getControl("Description").value = "Recipe description"
+        browser.getControl("Git repository").value = (
+            git_ref.repository.identity)
+        browser.getControl("Git branch").value = git_ref.path
+        browser.getControl("Create OCI recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("recipe-name", extract_text(content.h1))
+        self.assertThat(
+            "Recipe description",
+            MatchesTagText(content, "recipe-description"))
+        self.assertThat(
+            "Test Person", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "OCI project:\n%s" % oci_project_display,
+            MatchesTagText(content, "oci-project"))
+        self.assertThat(
+            "Source:\n%s\nEdit OCI recipe" % source_display,
+            MatchesTagText(content, "source"))
+        self.assertThat(
+            "Build file path:\nDockerfile\nEdit OCI recipe",
+            MatchesTagText(content, "build-file"))
+        self.assertThat(
+            "Build schedule:\nBuilt on request\nEdit OCI recipe\n",
+            MatchesTagText(content, "build-schedule"))
+
+    def test_create_new_recipe_users_teams_as_owner_options(self):
+        # Teams that the user is in are options for the OCI recipe owner.
+        self.factory.makeTeam(
+            name="test-team", displayname="Test Team", members=[self.person])
+        oci_project = self.factory.makeOCIProject()
+        browser = self.getViewBrowser(
+            oci_project, view_name="+new-recipe", user=self.person)
+        options = browser.getControl("Owner").displayOptions
+        self.assertEqual(
+            ["Test Person (test-person)", "Test Team (test-team)"],
+            sorted(str(option) for option in options))
+
+
+class TestOCIRecipeAdminView(BaseTestOCIRecipeView):
+
+    def test_unauthorized(self):
+        # A non-admin user cannot administer an OCI recipe.
+        login_person(self.person)
+        recipe = self.factory.makeOCIRecipe(registrant=self.person)
+        recipe_url = canonical_url(recipe)
+        browser = self.getViewBrowser(recipe, user=self.person)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Administer OCI recipe")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, recipe_url + "/+admin",
+            user=self.person)
+
+    def test_admin_recipe(self):
+        # Admins can change require_virtualized.
+        login("admin@xxxxxxxxxxxxx")
+        commercial_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])
+        login_person(self.person)
+        recipe = self.factory.makeOCIRecipe(registrant=self.person)
+        self.assertTrue(recipe.require_virtualized)
+
+        browser = self.getViewBrowser(recipe, user=commercial_admin)
+        browser.getLink("Administer OCI recipe").click()
+        browser.getControl("Require virtualized builders").selected = False
+        browser.getControl("Update OCI recipe").click()
+
+        login_person(self.person)
+        self.assertFalse(recipe.require_virtualized)
+
+    def test_admin_recipe_sets_date_last_modified(self):
+        # Administering an OCI recipe sets the date_last_modified property.
+        login("admin@xxxxxxxxxxxxx")
+        ppa_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).ppa_admin])
+        login_person(self.person)
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, date_created=date_created)
+        login_person(ppa_admin)
+        view = OCIRecipeAdminView(recipe, LaunchpadTestRequest())
+        view.initialize()
+        view.request_action.success({"require_virtualized": False})
+        self.assertSqlAttributeEqualsDate(
+            recipe, "date_last_modified", UTC_NOW)
+
+
+class TestOCIRecipeEditView(BaseTestOCIRecipeView):
+
+    def test_edit_recipe(self):
+        oci_project = self.factory.makeOCIProject()
+        oci_project_display = oci_project.display_name
+        [old_git_ref] = self.factory.makeGitRefs()
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person,
+            oci_project=oci_project, git_ref=old_git_ref)
+        self.factory.makeTeam(
+            name="new-team", displayname="New Team", members=[self.person])
+        [new_git_ref] = self.factory.makeGitRefs()
+
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Edit OCI recipe").click()
+        browser.getControl("Owner").value = ["new-team"]
+        browser.getControl(name="field.name").value = "new-name"
+        browser.getControl("Description").value = "New description"
+        browser.getControl("Git repository").value = (
+            new_git_ref.repository.identity)
+        browser.getControl("Git branch").value = new_git_ref.path
+        browser.getControl("Build file path").value = "Dockerfile-2"
+        browser.getControl("Build daily").selected = True
+        browser.getControl("Update OCI recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("new-name", extract_text(content.h1))
+        self.assertThat("New Team", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "OCI project:\n%s" % oci_project_display,
+            MatchesTagText(content, "oci-project"))
+        self.assertThat(
+            "Source:\n%s\nEdit OCI recipe" % new_git_ref.display_name,
+            MatchesTagText(content, "source"))
+        self.assertThat(
+            "Build file path:\nDockerfile-2\nEdit OCI recipe",
+            MatchesTagText(content, "build-file"))
+        self.assertThat(
+            "Build schedule:\nBuilt daily\nEdit OCI recipe\n",
+            MatchesTagText(content, "build-schedule"))
+
+    def test_edit_recipe_sets_date_last_modified(self):
+        # Editing an OCI recipe sets the date_last_modified property.
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, date_created=date_created)
+        with person_logged_in(self.person):
+            view = OCIRecipeEditView(recipe, LaunchpadTestRequest())
+            view.initialize()
+            view.request_action.success({
+                "owner": recipe.owner,
+                "name": "changed",
+                "description": "changed",
+                })
+        self.assertSqlAttributeEqualsDate(
+            recipe, "date_last_modified", UTC_NOW)
+
+    def test_edit_recipe_already_exists(self):
+        oci_project = self.factory.makeOCIProject()
+        oci_project_display = oci_project.display_name
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person,
+            oci_project=oci_project, name="one")
+        self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person,
+            oci_project=oci_project, name="two")
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Edit OCI recipe").click()
+        browser.getControl(name="field.name").value = "two"
+        browser.getControl("Update OCI recipe").click()
+        self.assertEqual(
+            "There is already an OCI recipe owned by Test Person in %s with "
+            "this name." % oci_project_display,
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+
+class TestOCIRecipeDeleteView(BaseTestOCIRecipeView):
+
+    def test_unauthorized(self):
+        # A user without edit access cannot delete an OCI recipe.
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person)
+        recipe_url = canonical_url(recipe)
+        other_person = self.factory.makePerson()
+        browser = self.getViewBrowser(recipe, user=other_person)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Delete OCI recipe")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, recipe_url + "/+delete",
+            user=other_person)
+
+    def test_delete_recipe_without_builds(self):
+        # An OCI recipe without builds can be deleted.
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person)
+        recipe_url = canonical_url(recipe)
+        oci_project_url = canonical_url(recipe.oci_project)
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Delete OCI recipe").click()
+        browser.getControl("Delete OCI recipe").click()
+        self.assertEqual(oci_project_url, browser.url)
+        self.assertRaises(NotFound, browser.open, recipe_url)
+
+    def test_delete_recipe_with_builds(self):
+        # An OCI recipe with builds can be deleted.
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person)
+        self.factory.makeOCIRecipeBuild(recipe=recipe)
+        # XXX cjwatson 2020-02-19: This should also add a file to the build
+        # once that works.
+        recipe_url = canonical_url(recipe)
+        oci_project_url = canonical_url(recipe.oci_project)
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Delete OCI recipe").click()
+        browser.getControl("Delete OCI recipe").click()
+        self.assertEqual(oci_project_url, browser.url)
+        self.assertRaises(NotFound, browser.open, recipe_url)
+
+
+class TestOCIRecipeView(BaseTestOCIRecipeView):
+
+    def setUp(self):
+        super(TestOCIRecipeView, self).setUp()
+        self.distroseries = self.factory.makeDistroSeries()
+        processor = getUtility(IProcessorSet).getByName("386")
+        self.distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=self.distroseries, architecturetag="i386",
+            processor=processor)
+        self.factory.makeBuilder(virtualized=True)
+
+    def makeOCIRecipe(self, **kwargs):
+        return self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, name="recipe-name",
+            **kwargs)
+
+    def makeBuild(self, recipe=None, date_created=None, **kwargs):
+        if recipe is None:
+            recipe = self.makeOCIRecipe()
+        if date_created is None:
+            date_created = datetime.now(pytz.UTC) - timedelta(hours=1)
+        return self.factory.makeOCIRecipeBuild(
+            requester=self.person, recipe=recipe,
+            distro_arch_series=self.distroarchseries,
+            date_created=date_created, **kwargs)
+
+    def test_breadcrumb(self):
+        oci_project = self.factory.makeOCIProject()
+        oci_project_name = oci_project.name
+        oci_project_url = canonical_url(oci_project)
+        recipe = self.makeOCIRecipe(oci_project=oci_project)
+        view = create_view(recipe, "+index")
+        # To test the breadcrumbs we need a correct traversal stack.
+        view.request.traversed_objects = [self.person, recipe, view]
+        view.initialize()
+        breadcrumbs_tag = soupmatchers.Tag(
+            "breadcrumbs", "ol", attrs={"class": "breadcrumbs"})
+        self.assertThat(
+            view(),
+            soupmatchers.HTMLContains(
+                soupmatchers.Within(
+                    breadcrumbs_tag,
+                    soupmatchers.Tag(
+                        "OCI project breadcrumb", "a",
+                        text="%s OCI project" % oci_project_name,
+                        attrs={"href": oci_project_url})),
+                soupmatchers.Within(
+                    breadcrumbs_tag,
+                    soupmatchers.Tag(
+                        "OCI recipe breadcrumb", "li",
+                        text=re.compile(r"\srecipe-name\s")))))
+
+    def test_index(self):
+        oci_project = self.factory.makeOCIProject()
+        oci_project_name = oci_project.name
+        oci_project_display = oci_project.display_name
+        [ref] = self.factory.makeGitRefs(
+            owner=self.person, target=self.person, name="recipe-repository",
+            paths=["refs/heads/master"])
+        recipe = self.makeOCIRecipe(
+            oci_project=oci_project, git_ref=ref, build_file="Dockerfile")
+        build = self.makeBuild(
+            recipe=recipe, status=BuildStatus.FULLYBUILT,
+            duration=timedelta(minutes=30))
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            %s OCI project
+            recipe-name
+            .*
+            OCI recipe information
+            Owner: Test Person
+            OCI project: %s
+            Source: ~test-person/\\+git/recipe-repository:master
+            Build file path: Dockerfile
+            Build schedule: Built on request
+            Latest builds
+            Status When complete Architecture
+            Successfully built 30 minutes ago 386
+            """ % (oci_project_name, oci_project_display),
+            self.getMainText(build.recipe))
+
+    def test_index_success_with_buildlog(self):
+        # The build log is shown if it is there.
+        build = self.makeBuild(
+            status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
+        build.setLog(self.factory.makeLibraryFileAlias())
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture
+            Successfully built 30 minutes ago buildlog \(.*\) 386
+            """, self.getMainText(build.recipe))
+
+    def test_index_no_builds(self):
+        # A message is shown when there are no builds.
+        recipe = self.factory.makeOCIRecipe()
+        self.assertIn(
+            "This OCI recipe has not been built yet.",
+            self.getMainText(recipe))
+
+    def test_index_pending_build(self):
+        # A pending build is listed as such.
+        build = self.makeBuild()
+        build.queueBuild()
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture
+            Needs building in .* \(estimated\) 386
+            """, self.getMainText(build.recipe))
+
+    def setStatus(self, build, status):
+        build.updateStatus(
+            BuildStatus.BUILDING, date_started=build.date_created)
+        build.updateStatus(
+            status, date_finished=build.date_started + timedelta(minutes=30))
+
+    def test_builds(self):
+        # OCIRecipeView.builds produces reasonable results.
+        recipe = self.makeOCIRecipe()
+        # Create oldest builds first so that they sort properly by id.
+        date_gen = time_counter(
+            datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
+        builds = [
+            self.makeBuild(recipe=recipe, date_created=next(date_gen))
+            for i in range(11)]
+        view = OCIRecipeView(recipe, None)
+        self.assertEqual(list(reversed(builds)), view.builds)
+        self.setStatus(builds[10], BuildStatus.FULLYBUILT)
+        self.setStatus(builds[9], BuildStatus.FAILEDTOBUILD)
+        del get_property_cache(view).builds
+        # When there are >= 9 pending builds, only the most recent of any
+        # completed builds is returned.
+        self.assertEqual(
+            list(reversed(builds[:9])) + [builds[10]], view.builds)
+        for build in builds[:9]:
+            self.setStatus(build, BuildStatus.FULLYBUILT)
+        del get_property_cache(view).builds
+        self.assertEqual(list(reversed(builds[1:])), view.builds)
diff --git a/lib/lp/oci/browser/tests/test_ocirecipebuild.py b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
new file mode 100644
index 0000000..ed3cb9b
--- /dev/null
+++ b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
@@ -0,0 +1,80 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test OCI recipe build views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import re
+
+from storm.locals import Store
+from testtools.matchers import StartsWith
+
+from lp.buildmaster.enums import BuildStatus
+from lp.services.webapp import canonical_url
+from lp.testing import (
+    BrowserTestCase,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    )
+
+
+class TestCanonicalUrlForOCIRecipeBuild(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        distribution = self.factory.makeDistribution(name="distro")
+        oci_project = self.factory.makeOCIProject(
+            pillar=distribution, ociprojectname="oci-project")
+        recipe = self.factory.makeOCIRecipe(
+            name="recipe", registrant=owner, owner=owner,
+            oci_project=oci_project)
+        build = self.factory.makeOCIRecipeBuild(requester=owner, recipe=recipe)
+        self.assertThat(
+            canonical_url(build),
+            StartsWith(
+                "http://launchpad.test/~person/distro/+oci/oci-project/";
+                "+recipe/recipe/+build/"))
+
+
+class TestOCIRecipeBuildOperations(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIRecipeBuildOperations, self).setUp()
+        self.build = self.factory.makeOCIRecipeBuild()
+        self.build_url = canonical_url(self.build)
+
+    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(
+            "Build history.*%s" % re.escape(title),
+            extract_text(find_main_content(browser.contents)))
+        self.assertEqual(self.build_url, browser.getLink(title).url)
+
+    def makeBuildingOCIRecipe(self):
+        builder = self.factory.makeBuilder()
+        build = self.factory.makeOCIRecipeBuild()
+        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(self):
+        build = self.makeBuildingOCIRecipe()
+        browser = self.getViewBrowser(build.builder, no_login=True)
+        self.assertIn("tail of the log", browser.contents)
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 4a67bbb..b86afcb 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -7,6 +7,8 @@
     xmlns:lp="http://namespaces.canonical.com/lp";
     i18n_domain="launchpad">
 
+    <include package=".browser" />
+
     <!-- OCIRecipe -->
     <class
         class="lp.oci.model.ocirecipe.OCIRecipe">
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index f9ed77a..d93cbe9 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -248,5 +248,8 @@ class IOCIRecipeSet(Interface):
     def findByOwner(owner):
         """Return all OCI Recipes with the given `owner`."""
 
+    def findByOCIProject(oci_project):
+        """Return all OCI recipes with the given `oci_project`."""
+
     def preloadDataForOCIRecipes(recipes, user):
         """Load the data reloated to a list of OCI Recipes."""
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index 95af3f6..30c3835 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -14,7 +14,11 @@ __all__ = [
 
 from lazr.restful.fields import Reference
 from zope.interface import Interface
-from zope.schema import TextLine
+from zope.schema import (
+    Bool,
+    Datetime,
+    TextLine,
+    )
 
 from lp import _
 from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
@@ -51,6 +55,18 @@ class IOCIRecipeBuildView(IPackageBuild):
         required=True,
         readonly=True)
 
+    eta = Datetime(
+        title=_("The datetime when the build job is estimated to complete."),
+        readonly=True)
+
+    estimate = Bool(
+        title=_("If true, the date value is an estimate."), readonly=True)
+
+    date = Datetime(
+        title=_(
+            "The date when the build completed or is estimated to complete."),
+        readonly=True)
+
     def getByFileName():
         """Retrieve a file by filename
 
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 4555a38..2bf8068 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -309,6 +309,11 @@ class OCIRecipeSet:
         """See `IOCIRecipe`."""
         return IStore(OCIRecipe).find(OCIRecipe, OCIRecipe.owner == owner)
 
+    def findByOCIProject(self, oci_project):
+        """See `IOCIRecipe`."""
+        return IStore(OCIRecipe).find(
+            OCIRecipe, OCIRecipe.oci_project == oci_project)
+
     def preloadDataForOCIRecipes(self, recipes, user=None):
         """See `IOCIRecipeSet`."""
         recipes = [removeSecurityProxy(recipe) for recipe in recipes]
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 307fbd2..c010817 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -32,6 +32,7 @@ from zope.interface import implementer
 from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import (
     BuildFarmJobType,
+    BuildQueueStatus,
     BuildStatus,
     )
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
@@ -56,6 +57,7 @@ from lp.services.librarian.model import (
     LibraryFileAlias,
     LibraryFileContent,
     )
+from lp.services.propertycache import cachedproperty
 
 
 @implementer(IOCIFile)
@@ -142,6 +144,20 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
         self.status = BuildStatus.NEEDSBUILD
         self.build_farm_job = build_farm_job
 
+    def __repr__(self):
+        return "<OCIRecipeBuild ~%s/%s/+oci/%s/+recipe/%s/+build/%d>" % (
+            self.recipe.owner.name, self.recipe.oci_project.pillar.name,
+            self.recipe.oci_project.name, self.recipe.name, self.id)
+
+    @property
+    def title(self):
+        # XXX cjwatson 2020-02-19: This should use a DAS architecture tag
+        # rather than a processor name once we can do that.
+        return "%s build of ~%s/%s/+oci/%s/+recipe/%s" % (
+            self.processor.name, self.recipe.owner.name,
+            self.recipe.oci_project.pillar.name, self.recipe.oci_project.name,
+            self.recipe.name)
+
     def calculateScore(self):
         # XXX twom 2020-02-11 - This might need an addition?
         return 2510
@@ -196,6 +212,39 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
         IMasterStore(OCIFile).add(oci_file)
         return oci_file
 
+    @cachedproperty
+    def eta(self):
+        """The datetime when the build job is estimated to complete.
+
+        This is the BuildQueue.estimated_duration plus the
+        Job.date_started or BuildQueue.getEstimatedJobStartTime.
+        """
+        if self.buildqueue_record is None:
+            return None
+        queue_record = self.buildqueue_record
+        if queue_record.status == BuildQueueStatus.WAITING:
+            start_time = queue_record.getEstimatedJobStartTime()
+        else:
+            start_time = queue_record.date_started
+        if start_time is None:
+            return None
+        duration = queue_record.estimated_duration
+        return start_time + duration
+
+    @property
+    def estimate(self):
+        """If true, the date value is an estimate."""
+        if self.date_finished is not None:
+            return False
+        return self.eta is not None
+
+    @property
+    def date(self):
+        """The date when the build completed or is estimated to complete."""
+        if self.estimate:
+            return self.eta
+        return self.date_finished
+
     @property
     def archive(self):
         # XXX twom 2019-12-05 This may need to change when an OCIProject
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
new file mode 100644
index 0000000..fc75d75
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -0,0 +1,118 @@
+<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_side"
+  i18n:domain="launchpad"
+>
+
+<body>
+  <metal:registering fill-slot="registering">
+    Created by
+      <tal:registrant replace="structure context/registrant/fmt:link"/>
+    on
+      <tal:created-on replace="structure context/date_created/fmt:date"/>
+    and last modified on
+      <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
+  </metal:registering>
+
+  <metal:side fill-slot="side">
+    <div tal:replace="structure context/@@+global-actions"/>
+  </metal:side>
+
+  <metal:heading fill-slot="heading">
+    <h1 tal:content="context/name"/>
+  </metal:heading>
+
+  <div metal:fill-slot="main">
+    <div id="recipe-description" tal:condition="context/description"
+         class="summary"
+         tal:content="structure context/description/fmt:text-to-html"/>
+
+    <h2>OCI recipe information</h2>
+    <div class="two-column-list">
+      <dl id="owner">
+        <dt>Owner:</dt>
+        <dd tal:content="structure view/person_picker"/>
+      </dl>
+      <dl id="oci-project" tal:define="oci_project context/oci_project">
+        <dt>OCI project:</dt>
+        <dd>
+          <a tal:attributes="href oci_project/fmt:url"
+             tal:content="oci_project/display_name"/>
+        </dd>
+      </dl>
+      <dl id="source" tal:define="source context/git_ref">
+        <dt>Source:</dt>
+        <dd>
+          <a tal:replace="structure source/fmt:link"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+      <dl id="build-file">
+        <dt>Build file path:</dt>
+        <dd>
+          <span tal:content="context/build_file"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+      <dl id="build-schedule">
+        <dt>Build schedule:</dt>
+        <dd>
+          <span tal:replace="view/build_frequency"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+    </div>
+
+    <h2>Latest builds</h2>
+    <table id="latest-builds-listing" class="listing"
+           style="margin-bottom: 1em;">
+      <thead>
+        <tr>
+          <th>Status</th>
+          <th>When complete</th>
+          <th>Architecture</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tal:recipe-builds repeat="item view/builds">
+          <tr tal:define="build item"
+              tal:attributes="id string:build-${build/id}">
+            <td tal:attributes="class string:build_status ${build/status/name}">
+              <span tal:replace="structure build/image:icon"/>
+              <a tal:content="build/status/title"
+                 tal:attributes="href build/fmt:url"/>
+            </td>
+            <td class="datebuilt">
+              <tal:date replace="build/date/fmt:displaydate"/>
+              <tal:estimate condition="build/estimate">
+                (estimated)
+              </tal:estimate>
+
+              <tal:build-log define="file build/log" tal:condition="file">
+                <a class="sprite download"
+                   tal:attributes="href build/log_url">buildlog</a>
+                (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+              </tal:build-log>
+            </td>
+            <td>
+              <!-- XXX cjwatson 2020-02-19: This should show a DAS
+                   architecture tag rather than a processor name once we can
+                   do that. -->
+              <a class="sprite distribution"
+                 tal:define="processor build/processor"
+                 tal:content="processor/name"/>
+            </td>
+          </tr>
+        </tal:recipe-builds>
+      </tbody>
+    </table>
+    <p tal:condition="not: view/builds">
+      This OCI recipe has not been built yet.
+    </p>
+  </div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
new file mode 100644
index 0000000..a2d3809
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-new.pt
@@ -0,0 +1,41 @@
+<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_side"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <!-- XXX cjwatson 2020-02-18: Add an introductory paragraph explaining
+       what OCI recipes are. -->
+
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+      <table class="form">
+        <tal:widget define="widget nocall:view/widgets/name">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/owner">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/description">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/git_ref">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/build_file">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/build_daily">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+      </table>
+    </metal:formbody>
+  </div>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipebuild-index.pt b/lib/lp/oci/templates/ocirecipebuild-index.pt
new file mode 100644
index 0000000..04b7fcf
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipebuild-index.pt
@@ -0,0 +1,151 @@
+<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>
+
+
+<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>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>
+    </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>
+  </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/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index 3d51de2..b768e1d 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 from datetime import timedelta
 
 import six
+from testtools.matchers import Equals
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -20,14 +21,17 @@ from lp.oci.interfaces.ocirecipebuild import (
     IOCIRecipeBuildSet,
     )
 from lp.oci.model.ocirecipebuild import OCIRecipeBuildSet
+from lp.services.propertycache import clear_property_cache
 from lp.testing import (
     admin_logged_in,
+    StormStatementRecorder,
     TestCaseWithFactory,
     )
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadZopelessLayer,
     )
+from lp.testing.matchers import HasQueryCount
 
 
 class TestOCIRecipeBuild(TestCaseWithFactory):
@@ -105,6 +109,33 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
         self.assertIsNotNone(bq.processor)
         self.assertEqual(bq, self.build.buildqueue_record)
 
+    def test_eta(self):
+        # OCIRecipeBuild.eta returns a non-None value when it should, or
+        # None when there's no start time.
+        self.build.queueBuild()
+        self.assertIsNone(self.build.eta)
+        self.factory.makeBuilder(processors=[self.build.processor])
+        clear_property_cache(self.build)
+        self.assertIsNotNone(self.build.eta)
+
+    def test_eta_cached(self):
+        # The expensive completion time estimate is cached.
+        self.build.queueBuild()
+        self.build.eta
+        with StormStatementRecorder() as recorder:
+            self.build.eta
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+
+    def test_estimate(self):
+        # OCIRecipeBuild.estimate returns True until the job is completed.
+        self.build.queueBuild()
+        self.factory.makeBuilder(processors=[self.build.processor])
+        self.build.updateStatus(BuildStatus.BUILDING)
+        self.assertTrue(self.build.estimate)
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        clear_property_cache(self.build)
+        self.assertFalse(self.build.estimate)
+
 
 class TestOCIRecipeBuildSet(TestCaseWithFactory):
 
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index 993fe23..72864b9 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -633,7 +633,8 @@
         module="lp.registry.browser.ociproject"
         classes="
             OCIProjectFacets
-            OCIProjectNavigationMenu"
+            OCIProjectNavigationMenu
+            OCIProjectContextMenu"
         />
     <adapter
         name="fmt"
diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
index 4d760a4..3562a03 100644
--- a/lib/lp/registry/browser/ociproject.py
+++ b/lib/lp/registry/browser/ociproject.py
@@ -8,8 +8,10 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 __all__ = [
     'OCIProjectBreadcrumb',
+    'OCIProjectContextMenu',
     'OCIProjectFacets',
     'OCIProjectNavigation',
+    'OCIProjectNavigationMenu',
     ]
 
 from zope.component import getUtility
@@ -20,14 +22,15 @@ from lp.app.browser.launchpadform import (
     LaunchpadEditFormView,
     )
 from lp.app.browser.tales import CustomizableFormatter
-from lp.app.interfaces.headings import IHeadingBreadcrumb
 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
+from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.registry.interfaces.ociproject import (
     IOCIProject,
     IOCIProjectSet,
     )
 from lp.services.webapp import (
     canonical_url,
+    ContextMenu,
     enabled_with_permission,
     Link,
     Navigation,
@@ -53,7 +56,7 @@ class OCIProjectNavigation(TargetDefaultVCSNavigationMixin, Navigation):
     usedfor = IOCIProject
 
 
-@implementer(IHeadingBreadcrumb, IMultiFacetedBreadcrumb)
+@implementer(IMultiFacetedBreadcrumb)
 class OCIProjectBreadcrumb(Breadcrumb):
     """Builds a breadcrumb for an `IOCIProject`."""
 
@@ -85,6 +88,26 @@ class OCIProjectNavigationMenu(NavigationMenu):
         return Link('+edit', 'Edit OCI project', icon='edit')
 
 
+class OCIProjectContextMenu(ContextMenu):
+    """Context menu for OCI projects."""
+
+    usedfor = IOCIProject
+
+    facet = 'overview'
+
+    links = ('create_recipe', 'view_recipes')
+
+    @enabled_with_permission('launchpad.AnyLegitimatePerson')
+    def create_recipe(self):
+        return Link('+new-recipe', 'Create OCI recipe', icon='add')
+
+    def view_recipes(self):
+        enabled = not getUtility(IOCIRecipeSet).findByOCIProject(
+            self.context).is_empty()
+        return Link(
+            '+recipes', 'View OCI recipes', icon='info', enabled=enabled)
+
+
 class OCIProjectEditView(LaunchpadEditFormView):
     """Edit an OCI project."""
 
diff --git a/lib/lp/registry/browser/personociproject.py b/lib/lp/registry/browser/personociproject.py
index aa991eb..3b81dba 100644
--- a/lib/lp/registry/browser/personociproject.py
+++ b/lib/lp/registry/browser/personociproject.py
@@ -10,16 +10,21 @@ __all__ = [
     'PersonOCIProjectNavigation',
     ]
 
-from zope.component import queryAdapter
+from zope.component import (
+    getUtility,
+    queryAdapter,
+    )
 from zope.interface import implementer
 from zope.traversing.interfaces import IPathAdapter
 
 from lp.code.browser.vcslisting import PersonTargetDefaultVCSNavigationMixin
+from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.registry.interfaces.personociproject import IPersonOCIProject
 from lp.services.webapp import (
     canonical_url,
     Navigation,
     StandardLaunchpadFacets,
+    stepthrough,
     )
 from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
@@ -30,6 +35,11 @@ class PersonOCIProjectNavigation(
 
     usedfor = IPersonOCIProject
 
+    @stepthrough('+recipe')
+    def traverse_recipe(self, name):
+        return getUtility(IOCIRecipeSet).getByName(
+            self.context.person, self.context.oci_project, name)
+
 
 # XXX cjwatson 2019-11-26: Do we need two breadcrumbs, one for the
 # distribution and one for the OCI project?
diff --git a/lib/lp/registry/templates/ociproject-index.pt b/lib/lp/registry/templates/ociproject-index.pt
index 8a9a6a1..427b383 100644
--- a/lib/lp/registry/templates/ociproject-index.pt
+++ b/lib/lp/registry/templates/ociproject-index.pt
@@ -33,17 +33,27 @@
         <dd>
           <a tal:attributes="href distribution/fmt:url"
              tal:content="distribution/display_name"/>
-          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+          <a tal:replace="structure context/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
       <dl id="name">
         <dt>Name:</dt>
         <dd>
           <span tal:content="context/name"/>
-          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+          <a tal:replace="structure context/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
     </div>
+
+    <h2>Recipes</h2>
+    <div id="recipe-summary"
+         tal:define="link context/menu:context/view_recipes"
+         tal:condition="link/enabled"
+         tal:content="structure link/render"/>
+    <tal:create-recipe
+        define="link context/menu:context/create_recipe"
+        condition="link/enabled"
+        replace="structure link/render"/>
   </div>
 </body>
 </html>