← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~jugmac00/launchpad:add-a-basic-rock-recipe-index-view into launchpad:master

 

Jürgen Gmach has proposed merging ~jugmac00/launchpad:add-a-basic-rock-recipe-index-view into launchpad:master with ~jugmac00/launchpad:delete-rock-recipe-builds-and-jobs-when-deleting-recipes as a prerequisite.

Commit message:
Add a basic rock recipe +index view

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/473273
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:add-a-basic-rock-recipe-index-view into launchpad:master.
diff --git a/lib/lp/app/browser/configure.zcml b/lib/lp/app/browser/configure.zcml
index 70e44d9..57a699a 100644
--- a/lib/lp/app/browser/configure.zcml
+++ b/lib/lp/app/browser/configure.zcml
@@ -596,6 +596,13 @@
       />
 
   <adapter
+      for="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuild"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.BuildImageDisplayAPI"
+      name="image"
+      />
+
+  <adapter
       for="lp.soyuz.interfaces.archive.IArchive"
       provides="zope.traversing.interfaces.IPathAdapter"
       factory="lp.app.browser.tales.ArchiveImageDisplayAPI"
diff --git a/lib/lp/rocks/browser/configure.zcml b/lib/lp/rocks/browser/configure.zcml
index b70835f..58cb182 100644
--- a/lib/lp/rocks/browser/configure.zcml
+++ b/lib/lp/rocks/browser/configure.zcml
@@ -12,9 +12,29 @@
         <lp:url
             for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
             urldata="lp.rocks.browser.rockrecipe.RockRecipeURL" />
+<<<<<<< lib/lp/rocks/browser/configure.zcml
         <lp:navigation
             module="lp.rocks.browser.rockrecipe"
             classes="RockRecipeNavigation" />
+=======
+        <browser:defaultView
+            for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
+            name="+index" />
+        <browser:page
+            for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
+            class="lp.rocks.browser.rockrecipe.RockRecipeView"
+            permission="launchpad.View"
+            name="+index"
+            template="../templates/rockrecipe-index.pt" />
+        <lp:navigation
+            module="lp.rocks.browser.rockrecipe"
+            classes="RockRecipeNavigation" />
+        <adapter
+            provides="lp.services.webapp.interfaces.IBreadcrumb"
+            for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
+            factory="lp.rocks.browser.rockrecipe.RockRecipeBreadcrumb"
+            permission="zope.Public" />
+>>>>>>> lib/lp/rocks/browser/configure.zcml
         <lp:url
             for="lp.rocks.interfaces.rockrecipe.IRockRecipeBuildRequest"
             path_expression="string:+build-request/${id}"
diff --git a/lib/lp/rocks/browser/rockrecipe.py b/lib/lp/rocks/browser/rockrecipe.py
index 0e13dbe..64fa233 100644
--- a/lib/lp/rocks/browser/rockrecipe.py
+++ b/lib/lp/rocks/browser/rockrecipe.py
@@ -6,15 +6,30 @@
 __all__ = [
     "RockRecipeNavigation",
     "RockRecipeURL",
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
+=======
+    "RockRecipeView",
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
 ]
 
 from zope.component import getUtility
 from zope.interface import implementer
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
+=======
+from zope.security.interfaces import Unauthorized
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
 
 from lp.registry.interfaces.personproduct import IPersonProductFactory
 from lp.rocks.interfaces.rockrecipe import IRockRecipe
 from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuildSet
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
 from lp.services.webapp import Navigation, stepthrough
+=======
+from lp.services.propertycache import cachedproperty
+from lp.services.utils import seconds_since_epoch
+from lp.services.webapp import LaunchpadView, Navigation, stepthrough
+from lp.services.webapp.breadcrumb import Breadcrumb, NameBreadcrumb
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
 from lp.services.webapp.interfaces import ICanonicalUrlData
 from lp.soyuz.browser.build import get_build_by_id_str
 
@@ -56,3 +71,100 @@ class RockRecipeNavigation(Navigation):
         if build is None or build.recipe != self.context:
             return None
         return build
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
+=======
+
+
+class RockRecipeBreadcrumb(NameBreadcrumb):
+
+    @property
+    def inside(self):
+        # XXX cjwatson 2021-06-04: This should probably link to an
+        # appropriate listing view, but we don't have one of those yet.
+        return Breadcrumb(
+            self.context.project,
+            text=self.context.project.display_name,
+            inside=self.context.project,
+        )
+
+
+class RockRecipeView(LaunchpadView):
+    """Default view of a rock recipe."""
+
+    @cachedproperty
+    def builds_and_requests(self):
+        return builds_and_requests_for_recipe(self.context)
+
+    @property
+    def build_frequency(self):
+        if self.context.auto_build:
+            return "Built automatically"
+        else:
+            return "Built on request"
+
+    @property
+    def sorted_auto_build_channels_items(self):
+        if self.context.auto_build_channels is None:
+            return []
+        return sorted(self.context.auto_build_channels.items())
+
+    @property
+    def store_channels(self):
+        return ", ".join(self.context.store_channels)
+
+    @property
+    def user_can_see_source(self):
+        try:
+            return self.context.source.visibleByUser(self.user)
+        except Unauthorized:
+            return False
+
+
+def builds_and_requests_for_recipe(recipe):
+    """A list of interesting builds and build requests.
+
+    All pending builds and pending build requests are shown, as well as up
+    to 10 recent builds and recent failed build requests.  Pending items are
+    ordered by the date they were created; recent items are ordered by the
+    date they finished (if available) or the date they started (if the date
+    they finished is not set due to an error).  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).
+    """
+
+    # We need to interleave items of different types, so SQL can't do all
+    # the sorting for us.
+    def make_sort_key(*date_attrs):
+        def _sort_key(item):
+            for date_attr in date_attrs:
+                if getattr(item, date_attr, None) is not None:
+                    return -seconds_since_epoch(getattr(item, date_attr))
+            return 0
+
+        return _sort_key
+
+    items = sorted(
+        list(recipe.pending_builds) + list(recipe.pending_build_requests),
+        key=make_sort_key("date_created", "date_requested"),
+    )
+    if len(items) < 10:
+        # We need to interleave two unbounded result sets, but we only need
+        # enough items from them to make the total count up to 10.  It's
+        # simplest to just fetch the upper bound from each set and do our
+        # own sorting.
+        recent_items = sorted(
+            list(recipe.completed_builds[: 10 - len(items)])
+            + list(recipe.failed_build_requests[: 10 - len(items)]),
+            key=make_sort_key(
+                "date_finished",
+                "date_started",
+                "date_created",
+                "date_requested",
+            ),
+        )
+        items.extend(recent_items[: 10 - len(items)])
+    return items
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
diff --git a/lib/lp/rocks/browser/tests/test_rockrecipe.py b/lib/lp/rocks/browser/tests/test_rockrecipe.py
index 229824d..fe4a083 100644
--- a/lib/lp/rocks/browser/tests/test_rockrecipe.py
+++ b/lib/lp/rocks/browser/tests/test_rockrecipe.py
@@ -3,11 +3,42 @@
 
 """Test rock recipe views."""
 
+<<<<<<< lib/lp/rocks/browser/tests/test_rockrecipe.py
 from lp.rocks.interfaces.rockrecipe import ROCK_RECIPE_ALLOW_CREATE
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp import canonical_url
 from lp.testing import TestCaseWithFactory
 from lp.testing.layers import DatabaseFunctionalLayer
+=======
+import re
+from datetime import datetime, timedelta, timezone
+
+import soupmatchers
+import transaction
+from fixtures import FakeLogger
+from testtools.matchers import Equals, MatchesListwise, MatchesStructure
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.rocks.browser.rockrecipe import RockRecipeView
+from lp.rocks.interfaces.rockrecipe import ROCK_RECIPE_ALLOW_CREATE
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.propertycache import get_property_cache
+from lp.services.webapp import canonical_url
+from lp.testing import (
+    BrowserTestCase,
+    TestCaseWithFactory,
+    person_logged_in,
+    time_counter,
+)
+from lp.testing.layers import DatabaseFunctionalLayer, LaunchpadFunctionalLayer
+from lp.testing.publication import test_traverse
+from lp.testing.views import create_initialized_view, create_view
+>>>>>>> lib/lp/rocks/browser/tests/test_rockrecipe.py
 
 
 class TestRockRecipeNavigation(TestCaseWithFactory):
@@ -28,3 +59,301 @@ class TestRockRecipeNavigation(TestCaseWithFactory):
             "http://launchpad.test/~person/project/+rock/rock";,
             canonical_url(recipe),
         )
+<<<<<<< lib/lp/rocks/browser/tests/test_rockrecipe.py
+=======
+
+    def test_rock_recipe(self):
+        recipe = self.factory.makeRockRecipe()
+        obj, _, _ = test_traverse(
+            "http://launchpad.test/~%s/%s/+rock/%s";
+            % (recipe.owner.name, recipe.project.name, recipe.name)
+        )
+        self.assertEqual(recipe, obj)
+
+
+class BaseTestRockRecipeView(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person"
+        )
+
+
+class TestRockRecipeView(BaseTestRockRecipeView):
+
+    def setUp(self):
+        super().setUp()
+        self.project = self.factory.makeProduct(
+            name="test-project", displayname="Test Project"
+        )
+        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        self.distroseries = self.factory.makeDistroSeries(
+            distribution=self.ubuntu
+        )
+        processor = getUtility(IProcessorSet).getByName("386")
+        self.distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=self.distroseries,
+            architecturetag="i386",
+            processor=processor,
+        )
+        self.factory.makeBuilder(virtualized=True)
+
+    def makeRockRecipe(self, **kwargs):
+        if "project" not in kwargs:
+            kwargs["project"] = self.project
+        if "git_ref" not in kwargs:
+            kwargs["git_ref"] = self.factory.makeGitRefs()[0]
+        return self.factory.makeRockRecipe(
+            registrant=self.person,
+            owner=self.person,
+            name="rock-name",
+            **kwargs,
+        )
+
+    def makeBuild(self, recipe=None, date_created=None, **kwargs):
+        if recipe is None:
+            recipe = self.makeRockRecipe()
+        if date_created is None:
+            datetime.now(timezone.utc) - timedelta(hours=1)
+        build = self.factory.makeRockRecipeBuild(
+            requester=self.person,
+            recipe=recipe,
+            distro_arch_series=self.distroarchseries,
+            date_created=date_created,
+            **kwargs,
+        )
+        job = removeSecurityProxy(
+            removeSecurityProxy(build.build_request)._job
+        )
+        job.job._status = JobStatus.COMPLETED
+        return build
+
+    def test_breadcrumb(self):
+        recipe = self.makeRockRecipe()
+        view = create_view(recipe, "+index")
+        # To test the breadcrumbs we need a correct traversal stack.
+        view.request.traversed_objects = [
+            recipe.owner,
+            recipe.project,
+            recipe,
+            view,
+        ]
+        view.initialize()
+        breadcrumbs_tag = soupmatchers.Tag(
+            "breadcrumbs", "ol", attrs={"class": "breadcrumbs"}
+        )
+        self.assertThat(
+            view(),
+            soupmatchers.HTMLContains(
+                soupmatchers.Within(
+                    breadcrumbs_tag,
+                    soupmatchers.Tag(
+                        "project breadcrumb",
+                        "a",
+                        text="Test Project",
+                        attrs={"href": re.compile(r"/test-project$")},
+                    ),
+                ),
+                soupmatchers.Within(
+                    breadcrumbs_tag,
+                    soupmatchers.Tag(
+                        "rock breadcrumb",
+                        "li",
+                        text=re.compile(r"\srock-name\s"),
+                    ),
+                ),
+            ),
+        )
+
+    def test_index_git(self):
+        [ref] = self.factory.makeGitRefs(
+            owner=self.person,
+            target=self.project,
+            name="rock-repository",
+            paths=["refs/heads/master"],
+        )
+        recipe = self.makeRockRecipe(git_ref=ref)
+        build = self.makeBuild(
+            recipe=recipe,
+            status=BuildStatus.FULLYBUILT,
+            duration=timedelta(minutes=30),
+        )
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            r"""\
+            Test Project
+            rock-name
+            .*
+            Rock recipe information
+            Owner: Test Person
+            Project: Test Project
+            Source: ~test-person/test-project/\+git/rock-repository:master
+            Build schedule: \(\?\)
+            Built on request
+            Builds of this rock recipe are not automatically uploaded to
+            the store.
+            Latest builds
+            Status When complete Architecture
+            Successfully built 30 minutes ago i386
+            """,
+            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(
+            r"""\
+            Latest builds
+            Status When complete Architecture
+            Successfully built 30 minutes ago buildlog \(.*\) i386
+            """,
+            self.getMainText(build.recipe),
+        )
+
+    def test_index_no_builds(self):
+        # A message is shown when there are no builds.
+        recipe = self.makeRockRecipe()
+        self.assertIn(
+            "This rock 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(
+            r"""\
+            Latest builds
+            Status When complete Architecture
+            Needs building in .* \(estimated\) i386
+            """,
+            self.getMainText(build.recipe),
+        )
+
+    def test_index_pending_build_request(self):
+        # A pending build request is listed as such.
+        recipe = self.makeRockRecipe()
+        with person_logged_in(recipe.owner):
+            recipe.requestBuilds(recipe.owner)
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            """\
+            Latest builds
+            Status When complete Architecture
+            Pending build request
+            """,
+            self.getMainText(recipe),
+        )
+
+    def test_index_failed_build_request(self):
+        # A failed build request is listed as such, with its error message.
+        recipe = self.makeRockRecipe()
+        with person_logged_in(recipe.owner):
+            request = recipe.requestBuilds(recipe.owner)
+        job = removeSecurityProxy(removeSecurityProxy(request)._job)
+        job.job._status = JobStatus.FAILED
+        job.job.date_finished = datetime.now(timezone.utc) - timedelta(hours=1)
+        job.error_message = "Boom"
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            r"""\
+            Latest builds
+            Status When complete Architecture
+            Failed build request 1 hour ago \(Boom\)
+            """,
+            self.getMainText(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_and_requests(self):
+        # RockRecipeView.builds_and_requests produces reasonable results,
+        # interleaving build requests with builds.
+        recipe = self.makeRockRecipe()
+        # Create oldest builds first so that they sort properly by id.
+        date_gen = time_counter(
+            datetime(2000, 1, 1, tzinfo=timezone.utc), timedelta(days=1)
+        )
+        builds = [
+            self.makeBuild(recipe=recipe, date_created=next(date_gen))
+            for i in range(3)
+        ]
+        self.setStatus(builds[2], BuildStatus.FULLYBUILT)
+        with person_logged_in(recipe.owner):
+            request = recipe.requestBuilds(recipe.owner)
+        job = removeSecurityProxy(removeSecurityProxy(request)._job)
+        job.job.date_created = next(date_gen)
+        view = RockRecipeView(recipe, None)
+        # The pending build request is interleaved in date order with
+        # pending builds, and these are followed by completed builds.
+        self.assertThat(
+            view.builds_and_requests,
+            MatchesListwise(
+                [
+                    MatchesStructure.byEquality(id=request.id),
+                    Equals(builds[1]),
+                    Equals(builds[0]),
+                    Equals(builds[2]),
+                ]
+            ),
+        )
+        transaction.commit()
+        builds.append(self.makeBuild(recipe=recipe))
+        del get_property_cache(view).builds_and_requests
+        self.assertThat(
+            view.builds_and_requests,
+            MatchesListwise(
+                [
+                    Equals(builds[3]),
+                    MatchesStructure.byEquality(id=request.id),
+                    Equals(builds[1]),
+                    Equals(builds[0]),
+                    Equals(builds[2]),
+                ]
+            ),
+        )
+        # If we pretend that the job failed, it is still listed, but after
+        # any pending builds.
+        job.job._status = JobStatus.FAILED
+        job.job.date_finished = job.date_created + timedelta(minutes=30)
+        del get_property_cache(view).builds_and_requests
+        self.assertThat(
+            view.builds_and_requests,
+            MatchesListwise(
+                [
+                    Equals(builds[3]),
+                    Equals(builds[1]),
+                    Equals(builds[0]),
+                    MatchesStructure.byEquality(id=request.id),
+                    Equals(builds[2]),
+                ]
+            ),
+        )
+
+    def test_store_channels_empty(self):
+        recipe = self.factory.makeRockRecipe()
+        view = create_initialized_view(recipe, "+index")
+        self.assertEqual("", view.store_channels)
+
+    def test_store_channels_display(self):
+        recipe = self.factory.makeRockRecipe(
+            store_channels=["track/stable/fix-123", "track/edge/fix-123"]
+        )
+        view = create_initialized_view(recipe, "+index")
+        self.assertEqual(
+            "track/stable/fix-123, track/edge/fix-123", view.store_channels
+        )
+>>>>>>> lib/lp/rocks/browser/tests/test_rockrecipe.py
diff --git a/lib/lp/rocks/configure.zcml b/lib/lp/rocks/configure.zcml
index bad9c80..5e63e08 100644
--- a/lib/lp/rocks/configure.zcml
+++ b/lib/lp/rocks/configure.zcml
@@ -13,6 +13,11 @@
     <lp:authorizations module=".security" />
     <include package=".browser" />
 
+<<<<<<< lib/lp/rocks/configure.zcml
+=======
+    <lp:help-folder folder="help" name="+help-rocks" />
+
+>>>>>>> lib/lp/rocks/configure.zcml
     <!-- RockRecipe -->
     <class class="lp.rocks.model.rockrecipe.RockRecipe">
         <require
diff --git a/lib/lp/rocks/help/rock-recipe-build-frequency.html b/lib/lp/rocks/help/rock-recipe-build-frequency.html
new file mode 100644
index 0000000..54364b4
--- /dev/null
+++ b/lib/lp/rocks/help/rock-recipe-build-frequency.html
@@ -0,0 +1,40 @@
+<html>
+  <head>
+    <title>Rock recipe build schedule</title>
+    <link rel="stylesheet" type="text/css" href="/+icing/combo.css" />
+    <style type="text/css">
+      dt {
+        font-weight: bold
+      }
+
+      dd p {
+        margin-bottom: 0.5em
+      }
+
+    </style>
+  </head>
+  <body>
+    <div class="yui-d0">
+      <h2>Rock recipe build schedule</h2>
+
+      <p>There are two options for when rock recipes get built:</p>
+      <dl>
+        <dt>Built automatically</dt>
+        <dd>
+          <p>A build will be scheduled automatically once a change to the
+            top-level source branch for the rock recipe is detected.</p>
+          <p>If there has been a build of the rock recipe within the previous
+            hour, the build will not be scheduled until an hour since the last
+            build.</p>
+          <p>If you really want the build to happen before the one-hour period
+            is up, you can use the "Request builds" action.</p>
+        </dd>
+        <dt>Built on request</dt>
+        <dd>
+          <p>Builds of the rock recipe have to be manually requested using
+            the "Request builds" action.</p>
+        </dd>
+      </dl>
+    </div>
+  </body>
+</html>
diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py
index c4a7085..2abc106 100644
--- a/lib/lp/rocks/interfaces/rockrecipe.py
+++ b/lib/lp/rocks/interfaces/rockrecipe.py
@@ -37,7 +37,11 @@ import http.client
 from lazr.enum import EnumeratedType, Item
 from lazr.restful.declarations import error_status, exported
 from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
 from zope.interface import Interface
+=======
+from zope.interface import Attribute, Interface
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
 from zope.schema import (
     Bool,
     Choice,
@@ -291,6 +295,11 @@ class IRockRecipeView(Interface):
         description=_("The person who registered this rock recipe."),
     )
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+    source = Attribute("The source branch for this rock recipe.")
+
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     private = Bool(
         title=_("Private"),
         required=False,
@@ -372,6 +381,56 @@ class IRockRecipeView(Interface):
         :return: `IRockRecipeBuildRequest`.
         """
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+    pending_build_requests = CollectionField(
+        title=_("Pending build requests for this rock recipe."),
+        value_type=Reference(IRockRecipeBuildRequest),
+        required=True,
+        readonly=True,
+    )
+
+    failed_build_requests = CollectionField(
+        title=_("Failed build requests for this rock recipe."),
+        value_type=Reference(IRockRecipeBuildRequest),
+        required=True,
+        readonly=True,
+    )
+
+    builds = CollectionField(
+        title=_("All builds of this rock recipe."),
+        description=_(
+            "All builds of this rock recipe, sorted in descending order "
+            "of finishing (or starting if not completed successfully)."
+        ),
+        # Really IRockRecipeBuild.
+        value_type=Reference(schema=Interface),
+        readonly=True,
+    )
+
+    completed_builds = CollectionField(
+        title=_("Completed builds of this rock recipe."),
+        description=_(
+            "Completed builds of this rock recipe, sorted in descending "
+            "order of finishing."
+        ),
+        # Really IRockRecipeBuild.
+        value_type=Reference(schema=Interface),
+        readonly=True,
+    )
+
+    pending_builds = CollectionField(
+        title=_("Pending builds of this rock recipe."),
+        description=_(
+            "Pending builds of this rock recipe, sorted in descending "
+            "order of creation."
+        ),
+        # Really IRockRecipeBuild.
+        value_type=Reference(schema=Interface),
+        readonly=True,
+    )
+
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
 
 class IRockRecipeEdit(Interface):
     """`IRockRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
index 2070951..bf575b6 100644
--- a/lib/lp/rocks/model/rockrecipe.py
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -14,6 +14,12 @@ from operator import itemgetter
 from lazr.lifecycle.event import ObjectCreatedEvent
 from storm.databases.postgres import JSON
 from storm.locals import (
+    Bool,
+    DateTime,
+    Int,
+    Join,
+    Or,
+    Reference,
 =======
 from datetime import timezone
 from operator import attrgetter, itemgetter
@@ -23,15 +29,14 @@ from lazr.lifecycle.event import ObjectCreatedEvent
 from storm.databases.postgres import JSON
 from storm.locals import (
     And,
->>>>>>> lib/lp/rocks/model/rockrecipe.py
     Bool,
     DateTime,
+    Desc,
     Int,
     Join,
+    Not,
     Or,
     Reference,
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
-=======
     Select,
 >>>>>>> lib/lp/rocks/model/rockrecipe.py
     Store,
@@ -50,6 +55,8 @@ from lp.app.enums import (
 from lp.buildmaster.enums import BuildStatus
 <<<<<<< lib/lp/rocks/model/rockrecipe.py
 =======
+from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
+from lp.buildmaster.model.builder import Builder
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.errors import GitRepositoryBlobNotFound, GitRepositoryScanFault
@@ -105,10 +112,13 @@ from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.interfaces import IPrimaryStore, IStore
 from lp.services.database.stormbase import StormBase
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
 from lp.services.features import getFeatureFlag
 from lp.services.job.interfaces.job import JobStatus
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
 =======
+from lp.services.database.stormexpr import Greatest, NullsLast
+from lp.services.features import getFeatureFlag
+from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import Job
 >>>>>>> lib/lp/rocks/model/rockrecipe.py
 from lp.services.librarian.model import LibraryFileAlias
@@ -360,6 +370,14 @@ class RockRecipe(StormBase):
             self.git_path = None
 
     @property
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
+=======
+    def source(self):
+        """See `IRockRecipe`."""
+        return self.git_ref
+
+    @property
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
     def store_channels(self):
         """See `IRockRecipe`."""
         return self._store_channels or []
@@ -575,11 +593,105 @@ class RockRecipe(StormBase):
         """See `IRockRecipe`."""
         return RockRecipeBuildRequest(self, job_id)
 
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
     def destroySelf(self):
         """See `IRockRecipe`."""
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
         IStore(RockRecipe).remove(self)
 =======
+    @property
+    def pending_build_requests(self):
+        """See `IRockRecipe`."""
+        job_source = getUtility(IRockRecipeRequestBuildsJobSource)
+        # The returned jobs are ordered by descending ID.
+        jobs = job_source.findByRecipe(
+            self, statuses=(JobStatus.WAITING, JobStatus.RUNNING)
+        )
+        return DecoratedResultSet(
+            jobs, result_decorator=RockRecipeBuildRequest.fromJob
+        )
+
+    @property
+    def failed_build_requests(self):
+        """See `IRockRecipe`."""
+        job_source = getUtility(IRockRecipeRequestBuildsJobSource)
+        # The returned jobs are ordered by descending ID.
+        jobs = job_source.findByRecipe(self, statuses=(JobStatus.FAILED,))
+        return DecoratedResultSet(
+            jobs, result_decorator=RockRecipeBuildRequest.fromJob
+        )
+
+    def _getBuilds(self, filter_term, order_by):
+        """The actual query to get the builds."""
+        query_args = [
+            RockRecipeBuild.recipe == self,
+        ]
+        if filter_term is not None:
+            query_args.append(filter_term)
+        result = Store.of(self).find(RockRecipeBuild, *query_args)
+        result.order_by(order_by)
+
+        def eager_load(rows):
+            getUtility(IRockRecipeBuildSet).preloadBuildsData(rows)
+            getUtility(IBuildQueueSet).preloadForBuildFarmJobs(rows)
+            load_related(Builder, rows, ["builder_id"])
+
+        return DecoratedResultSet(result, pre_iter_hook=eager_load)
+
+    @property
+    def builds(self):
+        """See `IRockRecipe`."""
+        order_by = (
+            NullsLast(
+                Desc(
+                    Greatest(
+                        RockRecipeBuild.date_started,
+                        RockRecipeBuild.date_finished,
+                    )
+                )
+            ),
+            Desc(RockRecipeBuild.date_created),
+            Desc(RockRecipeBuild.id),
+        )
+        return self._getBuilds(None, order_by)
+
+    @property
+    def _pending_states(self):
+        """All the build states we consider pending (non-final)."""
+        return [
+            BuildStatus.NEEDSBUILD,
+            BuildStatus.BUILDING,
+            BuildStatus.UPLOADING,
+            BuildStatus.CANCELLING,
+        ]
+
+    @property
+    def completed_builds(self):
+        """See `IRockRecipe`."""
+        filter_term = Not(RockRecipeBuild.status.is_in(self._pending_states))
+        order_by = (
+            NullsLast(
+                Desc(
+                    Greatest(
+                        RockRecipeBuild.date_started,
+                        RockRecipeBuild.date_finished,
+                    )
+                )
+            ),
+            Desc(RockRecipeBuild.id),
+        )
+        return self._getBuilds(filter_term, order_by)
+
+    @property
+    def pending_builds(self):
+        """See `IRockRecipe`."""
+        filter_term = RockRecipeBuild.status.is_in(self._pending_states)
+        # We want to order by date_created but this is the same as ordering
+        # by id (since id increases monotonically) and is less expensive.
+        order_by = Desc(RockRecipeBuild.id)
+        return self._getBuilds(filter_term, order_by)
+
+    def destroySelf(self):
+        """See `IRockRecipe`."""
         store = IStore(self)
         # Remove build jobs. There won't be many queued builds, so we can
         # afford to do this the safe but slow way via BuildQueue.destroySelf
diff --git a/lib/lp/rocks/templates/rockrecipe-index.pt b/lib/lp/rocks/templates/rockrecipe-index.pt
new file mode 100644
index 0000000..ca29972
--- /dev/null
+++ b/lib/lp/rocks/templates/rockrecipe-index.pt
@@ -0,0 +1,166 @@
+<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">
+    <h2>Rock recipe information</h2>
+    <div class="two-column-list">
+      <dl id="owner">
+        <dt>Owner:</dt>
+        <dd tal:content="structure context/owner/fmt:link"/>
+      </dl>
+      <dl id="project" tal:define="project context/project">
+        <dt>Project:</dt>
+        <dd>
+          <a tal:attributes="href context/project/fmt:url"
+             tal:content="context/project/display_name"/>
+        </dd>
+      </dl>
+      <dl id="source"
+          tal:define="source context/source" tal:condition="source">
+        <dt>Source:</dt>
+        <dd tal:condition="view/user_can_see_source">
+          <a tal:replace="structure source/fmt:link"/>
+        </dd>
+        <dd tal:condition="not: view/user_can_see_source">
+          <span class="sprite private">&lt;redacted&gt;</span>
+        </dd>
+      </dl>
+
+      <dl id="auto_build">
+        <dt>Build schedule:
+          <a href="/+help-rocks/rock-recipe-build-frequency.html"
+             target="help" class="sprite maybe action-icon">(?)</a>
+        </dt>
+        <dd>
+          <span tal:replace="view/build_frequency"/>
+        </dd>
+      </dl>
+      <dl id="auto_build_channels" tal:condition="context/auto_build_channels">
+        <dt>
+          Source snap channels for automatic builds:
+        </dt>
+        <dd>
+          <table class="listing compressed">
+            <tbody>
+              <tr tal:repeat="pair view/sorted_auto_build_channels_items">
+                <td tal:repeat="value pair" tal:content="value"/>
+              </tr>
+            </tbody>
+          </table>
+        </dd>
+      </dl>
+    </div>
+
+    <div id="store_upload" class="two-column-list"
+         tal:condition="context/store_upload">
+      <dl id="store_name">
+        <dt>Registered store package name:</dt>
+        <dd>
+          <span tal:content="context/store_name"/>
+        </dd>
+      </dl>
+      <dl id="store_channels" tal:condition="view/store_channels">
+        <dt>Store channels:</dt>
+        <dd>
+          <span tal:content="view/store_channels"/>
+        </dd>
+      </dl>
+      <p id="store_channels" tal:condition="not: view/store_channels">
+        This rock recipe will not be released to any channels on the store.
+      </p>
+    </div>
+    <p id="store_upload" tal:condition="not: context/store_upload">
+      Builds of this rock recipe are not automatically uploaded to the store.
+    </p>
+
+    <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:rock-recipe-builds-and-requests repeat="item view/builds_and_requests">
+          <tal:rock-recipe-build-request condition="item/date_requested|nothing">
+            <tr tal:define="request item"
+                tal:attributes="id string:request-${request/id}">
+              <td tal:attributes="class string:request_status ${request/status/name}">
+                <span tal:replace="structure request/image:icon"/>
+                <tal:title replace="request/status/title"/> build request
+              </td>
+              <td>
+                <tal:date condition="request/date_finished"
+                          replace="structure request/date_finished/fmt:displaydatetitle"/>
+                <tal:error-message condition="request/error_message">
+                  (<span tal:replace="request/error_message"/>)
+                </tal:error-message>
+              </td>
+              <td/>
+            </tr>
+          </tal:rock-recipe-build-request>
+          <tal:rock-recipe-build condition="not: item/date_requested|nothing">
+            <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="structure build/date/fmt:displaydatetitle"/>
+                <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>
+                <a class="sprite distribution"
+                   tal:define="archseries build/distro_arch_series"
+                   tal:attributes="href archseries/fmt:url"
+                   tal:content="archseries/architecturetag"/>
+              </td>
+            </tr>
+          </tal:rock-recipe-build>
+        </tal:rock-recipe-builds-and-requests>
+      </tbody>
+    </table>
+    <p tal:condition="not: view/builds_and_requests">
+      This rock recipe has not been built yet.
+    </p>
+  </div>
+
+</body>
+</html>

Follow ups