← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~jugmac00/launchpad:add-rock-recipe-listing-views into launchpad:master

 

Jürgen Gmach has proposed merging ~jugmac00/launchpad:add-rock-recipe-listing-views into launchpad:master with ~jugmac00/launchpad:add-basic-rock-recipe-build-views as a prerequisite.

Commit message:
Add rock listing views

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Based on charm listing views, see https://git.launchpad.net/launchpad/commit/?h=stable&id=ba72adee1c377f3a32555163d1546975a5be2027
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:add-rock-recipe-listing-views into launchpad:master.
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index 9cb9b72..9b93f05 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -43,6 +43,10 @@ from lp.code.interfaces.gitrepository import (
     IGitRepositorySet,
 )
 from lp.registry.interfaces.person import IPerson
+from lp.rocks.browser.hasrockrecipes import (
+    HasRockRecipesMenuMixin,
+    HasRockRecipesViewMixin,
+)
 from lp.services.config import config
 from lp.services.helpers import english_list
 from lp.services.propertycache import cachedproperty
@@ -58,6 +62,7 @@ class GitRefContextMenu(
     HasRecipesMenuMixin,
     HasSnapsMenuMixin,
     HasCharmRecipesMenuMixin,
+    HasRockRecipesMenuMixin,
 ):
     """Context menu for Git references."""
 
@@ -72,6 +77,7 @@ class GitRefContextMenu(
         "source",
         "view_charm_recipes",
         "view_recipes",
+        "view_rock_recipes",
     ]
 
     def source(self):
@@ -106,6 +112,7 @@ class GitRefView(
     HasSnapsViewMixin,
     HasCharmRecipesViewMixin,
     HasRevisionStatusReportsMixin,
+    HasRockRecipesViewMixin,
 ):
     # This is set at self.commit_infos, and should be accessed by the view
     # as self.commit_info_message.
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 28e0d40..4615a49 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -922,10 +922,16 @@ class GitRepository(
         # Clear cached references to the removed refs.
         # XXX cjwatson 2021-06-08: We should probably do something similar
         # for OCIRecipe, and for Snap if we start caching git_ref there.
+        # XXX jugmac00 2024-09-16: once we also include OCI and snaps, we
+        # should refactor this to a for loop in a for loop
         for recipe in getUtility(ICharmRecipeSet).findByGitRepository(
             self, paths=paths
         ):
             get_property_cache(recipe)._git_ref = None
+        for recipe in getUtility(IRockRecipeSet).findByGitRepository(
+            self, paths=paths
+        ):
+            get_property_cache(recipe)._git_ref = None
         self.date_last_modified = UTC_NOW
 
     def planRefChanges(self, hosting_path, logger=None):
diff --git a/lib/lp/code/templates/gitref-index.pt b/lib/lp/code/templates/gitref-index.pt
index 1916bb0..f4e8fe0 100644
--- a/lib/lp/code/templates/gitref-index.pt
+++ b/lib/lp/code/templates/gitref-index.pt
@@ -39,6 +39,7 @@
       <tal:ref-recipes replace="structure context/@@++ref-recipes" />
       <div metal:use-macro="context/@@+snap-macros/related-snaps" />
       <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
+      <div metal:use-macro="context/@@+rock-recipe-macros/related-rock-recipes" />
     </div>
   </div>
 
diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
index 6e0521a..bf3c6fd 100644
--- a/lib/lp/registry/browser/person.py
+++ b/lib/lp/registry/browser/person.py
@@ -167,6 +167,7 @@ from lp.registry.interfaces.teammembership import (
 from lp.registry.interfaces.wikiname import IWikiNameSet
 from lp.registry.mail.notification import send_direct_contact_email
 from lp.registry.model.person import get_recipients
+from lp.rocks.browser.hasrockrecipes import HasRockRecipesMenuMixin
 from lp.services.config import config
 from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.sqlbase import flush_database_updates
@@ -819,6 +820,7 @@ class PersonOverviewMenu(
     HasSnapsMenuMixin,
     HasOCIRecipesMenuMixin,
     HasCharmRecipesMenuMixin,
+    HasRockRecipesMenuMixin,
 ):
     usedfor = IPerson
     facet = "overview"
@@ -851,6 +853,7 @@ class PersonOverviewMenu(
         "related_software_summary",
         "view_charm_recipes",
         "view_recipes",
+        "view_rock_recipes",
         "view_snaps",
         "view_oci_recipes",
         "subscriptions",
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index 400192d..d63f11a 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -164,6 +164,7 @@ from lp.registry.interfaces.productrelease import (
 from lp.registry.interfaces.productseries import IProductSeries
 from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.rocks.browser.hasrockrecipes import HasRockRecipesMenuMixin
 from lp.services.config import config
 from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.features import getFeatureFlag
@@ -564,6 +565,7 @@ class ProductOverviewMenu(
     HasRecipesMenuMixin,
     HasSnapsMenuMixin,
     HasCharmRecipesMenuMixin,
+    HasRockRecipesMenuMixin,
 ):
     usedfor = IProduct
     facet = "overview"
@@ -590,6 +592,7 @@ class ProductOverviewMenu(
         "branding",
         "view_charm_recipes",
         "view_recipes",
+        "view_rock_recipes",
         "view_snaps",
         "create_charm_recipe",
         "create_snap",
diff --git a/lib/lp/registry/browser/team.py b/lib/lp/registry/browser/team.py
index 67c91b2..d3b919c 100644
--- a/lib/lp/registry/browser/team.py
+++ b/lib/lp/registry/browser/team.py
@@ -122,6 +122,7 @@ from lp.registry.interfaces.teammembership import (
     ITeamMembershipSet,
     TeamMembershipStatus,
 )
+from lp.rocks.browser.hasrockrecipes import HasRockRecipesMenuMixin
 from lp.security import ModerateByRegistryExpertsOrAdmins
 from lp.services.config import config
 from lp.services.features import getFeatureFlag
@@ -1782,6 +1783,7 @@ class TeamOverviewMenu(
     HasSnapsMenuMixin,
     HasOCIRecipesMenuMixin,
     HasCharmRecipesMenuMixin,
+    HasRockRecipesMenuMixin,
 ):
     usedfor = ITeam
     facet = "overview"
@@ -1811,6 +1813,7 @@ class TeamOverviewMenu(
         "related_software_summary",
         "view_charm_recipes",
         "view_recipes",
+        "view_rock_recipes",
         "view_snaps",
         "view_oci_recipes",
         "subscriptions",
diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
index fc3c274..2ad4aac 100644
--- a/lib/lp/registry/templates/product-index.pt
+++ b/lib/lp/registry/templates/product-index.pt
@@ -202,6 +202,11 @@
                   tal:condition="link/enabled">
                 <a tal:replace="structure link/fmt:link" />
               </li>
+              <li class="nowrap"
+                  tal:define="link context/menu:overview/view_rock_recipes"
+                  tal:condition="link/enabled">
+                <a tal:replace="structure link/fmt:link" />
+              </li>
             </ul>
           </div>
         </div>
diff --git a/lib/lp/rocks/browser/configure.zcml b/lib/lp/rocks/browser/configure.zcml
index 22a7d16..d26c8ff 100644
--- a/lib/lp/rocks/browser/configure.zcml
+++ b/lib/lp/rocks/browser/configure.zcml
@@ -83,6 +83,36 @@
             for="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuild"
             factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
             permission="zope.Public" />
+        <browser:page
+            for="*"
+            class="lp.app.browser.launchpad.Macro"
+            permission="zope.Public"
+            name="+rock-recipe-macros"
+            template="../templates/rockrecipe-macros.pt" />
+        <browser:page
+            for="lp.code.interfaces.gitrepository.IGitRepository"
+            class="lp.rocks.browser.rockrecipelisting.GitRockRecipeListingView"
+            permission="launchpad.View"
+            name="+rock-recipes"
+            template="../templates/rockrecipe-listing.pt" />
+        <browser:page
+            for="lp.code.interfaces.gitref.IGitRef"
+            class="lp.rocks.browser.rockrecipelisting.GitRockRecipeListingView"
+            permission="launchpad.View"
+            name="+rock-recipes"
+            template="../templates/rockrecipe-listing.pt" />
+        <browser:page
+            for="lp.registry.interfaces.person.IPerson"
+            class="lp.rocks.browser.rockrecipelisting.PersonRockRecipeListingView"
+            permission="launchpad.View"
+            name="+rock-recipes"
+            template="../templates/rockrecipe-listing.pt" />
+        <browser:page
+            for="lp.registry.interfaces.product.IProduct"
+            class="lp.rocks.browser.rockrecipelisting.ProjectRockRecipeListingView"
+            permission="launchpad.View"
+            name="+rock-recipes"
+            template="../templates/rockrecipe-listing.pt" />
 >>>>>>> lib/lp/rocks/browser/configure.zcml
     </lp:facet>
 </configure>
diff --git a/lib/lp/rocks/browser/hasrockrecipes.py b/lib/lp/rocks/browser/hasrockrecipes.py
new file mode 100644
index 0000000..7098352
--- /dev/null
+++ b/lib/lp/rocks/browser/hasrockrecipes.py
@@ -0,0 +1,65 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Mixins for browser classes for objects that have rock recipes."""
+
+__all__ = [
+    "HasRockRecipesMenuMixin",
+    "HasRockRecipesViewMixin",
+]
+
+from zope.component import getUtility
+
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
+from lp.services.webapp import Link, canonical_url
+from lp.services.webapp.escaping import structured
+
+
+class HasRockRecipesMenuMixin:
+    """A mixin for context menus for objects that have rock recipes."""
+
+    def view_rock_recipes(self):
+        text = "View rock recipes"
+        enabled = (
+            not getUtility(IRockRecipeSet)
+            .findByContext(self.context, visible_by_user=self.user)
+            .is_empty()
+        )
+        return Link("+rock-recipes", text, icon="info", enabled=enabled)
+
+
+class HasRockRecipesViewMixin:
+    """A view mixin for objects that have rock recipes."""
+
+    @property
+    def rock_recipes(self):
+        return getUtility(IRockRecipeSet).findByContext(
+            self.context, visible_by_user=self.user
+        )
+
+    @property
+    def rock_recipes_link(self):
+        """A link to charm recipes for this object."""
+        count = self.charm_recipes.count()
+        if IGitRepository.providedBy(self.context):
+            context_type = "repository"
+        else:
+            context_type = "branch"
+        if count == 0:
+            # Nothing to link to.
+            return "No charm recipes using this %s." % context_type
+        elif count == 1:
+            # Link to the single charm recipe.
+            return structured(
+                '<a href="%s">1 charm recipe</a> using this %s.',
+                canonical_url(self.charm_recipes.one()),
+                context_type,
+            ).escapedtext
+        else:
+            # Link to a charm recipe listing.
+            return structured(
+                '<a href="+charm-recipes">%s charm recipes</a> using this %s.',
+                count,
+                context_type,
+            ).escapedtext
diff --git a/lib/lp/rocks/browser/rockrecipelisting.py b/lib/lp/rocks/browser/rockrecipelisting.py
new file mode 100644
index 0000000..4ab4c11
--- /dev/null
+++ b/lib/lp/rocks/browser/rockrecipelisting.py
@@ -0,0 +1,71 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Base class view for rock recipe listings."""
+
+__all__ = [
+    "GitRockRecipeListingView",
+    "PersonRockRecipeListingView",
+    "ProjectRockRecipeListingView",
+]
+
+from functools import partial
+
+from zope.component import getUtility
+
+from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.feeds.browser import FeedsMixin
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp import LaunchpadView
+from lp.services.webapp.batching import BatchNavigator
+
+
+class RockRecipeListingView(LaunchpadView, FeedsMixin):
+
+    feed_types = ()
+
+    source_enabled = True
+    owner_enabled = True
+
+    @property
+    def page_title(self):
+        return "Rock recipes"
+
+    @property
+    def label(self):
+        return "Rock recipes for %(displayname)s" % {
+            "displayname": self.context.displayname
+        }
+
+    def initialize(self):
+        super().initialize()
+        recipes = getUtility(IRockRecipeSet).findByContext(
+            self.context, visible_by_user=self.user
+        )
+        loader = partial(
+            getUtility(IRockRecipeSet).preloadDataForRecipes, user=self.user
+        )
+        self.recipes = DecoratedResultSet(recipes, pre_iter_hook=loader)
+
+    @cachedproperty
+    def batchnav(self):
+        return BatchNavigator(self.recipes, self.request)
+
+
+class GitRockRecipeListingView(RockRecipeListingView):
+    source_enabled = False
+
+    @property
+    def label(self):
+        return "Rock recipes for %(display_name)s" % {
+            "display_name": self.context.display_name
+        }
+
+
+class PersonRockRecipeListingView(RockRecipeListingView):
+    owner_enabled = False
+
+
+class ProjectRockRecipeListingView(RockRecipeListingView):
+    pass
diff --git a/lib/lp/rocks/browser/tests/test_rockhasrecipes.py b/lib/lp/rocks/browser/tests/test_rockhasrecipes.py
new file mode 100644
index 0000000..40482fd
--- /dev/null
+++ b/lib/lp/rocks/browser/tests/test_rockhasrecipes.py
@@ -0,0 +1,88 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test views for objects that have rock recipes."""
+
+from testscenarios import WithScenarios, load_tests_apply_scenarios
+
+from lp.code.interfaces.gitrepository import IGitRepository
+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
+from lp.testing.views import create_initialized_view
+
+
+def make_git_repository(test_case):
+    return test_case.factory.makeGitRepository()
+
+
+def make_git_ref(test_case):
+    return test_case.factory.makeGitRefs()[0]
+
+
+class TestHasRockRecipesView(WithScenarios, TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    scenarios = [
+        (
+            "GitRepository",
+            {
+                "context_type": "repository",
+                "context_factory": make_git_repository,
+            },
+        ),
+        (
+            "GitRef",
+            {
+                "context_type": "branch",
+                "context_factory": make_git_ref,
+            },
+        ),
+    ]
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+
+    def makeRockRecipe(self, context):
+        if IGitRepository.providedBy(context):
+            [context] = self.factory.makeGitRefs(repository=context)
+        return self.factory.makeRockRecipe(git_ref=context)
+
+    def test_rock_recipes_link_no_recipes(self):
+        # An object with no rock recipes does not show a rock recipes link.
+        context = self.context_factory(self)
+        view = create_initialized_view(context, "+index")
+        self.assertEqual(
+            "No rock recipes using this %s." % self.context_type,
+            view.rock_recipes_link,
+        )
+
+    def test_rock_recipes_link_one_recipe(self):
+        # An object with one rock recipe shows a link to that recipe.
+        context = self.context_factory(self)
+        recipe = self.makeRockRecipe(context)
+        view = create_initialized_view(context, "+index")
+        expected_link = '<a href="%s">1 rock recipe</a> using this %s.' % (
+            canonical_url(recipe),
+            self.context_type,
+        )
+        self.assertEqual(expected_link, view.rock_recipes_link)
+
+    def test_rock_recipes_link_more_recipes(self):
+        # An object with more than one rock recipe shows a link to a listing.
+        context = self.context_factory(self)
+        self.makeRockRecipe(context)
+        self.makeRockRecipe(context)
+        view = create_initialized_view(context, "+index")
+        expected_link = (
+            '<a href="+rock-recipes">2 rock recipes</a> using this %s.'
+            % self.context_type
+        )
+        self.assertEqual(expected_link, view.rock_recipes_link)
+
+
+load_tests = load_tests_apply_scenarios
diff --git a/lib/lp/rocks/browser/tests/test_rockrecipelisting.py b/lib/lp/rocks/browser/tests/test_rockrecipelisting.py
new file mode 100644
index 0000000..8782263
--- /dev/null
+++ b/lib/lp/rocks/browser/tests/test_rockrecipelisting.py
@@ -0,0 +1,312 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test rock recipe listings."""
+
+from datetime import datetime, timedelta, timezone
+from functools import partial
+
+import soupmatchers
+from testtools.matchers import MatchesAll, Not
+from zope.security.proxy import removeSecurityProxy
+
+from lp.code.tests.helpers import GitHostingFixture
+from lp.rocks.interfaces.rockrecipe import ROCK_RECIPE_ALLOW_CREATE
+from lp.services.database.constants import ONE_DAY_AGO, UTC_NOW
+from lp.services.features.testing import MemoryFeatureFixture
+from lp.services.webapp import canonical_url
+from lp.testing import (
+    ANONYMOUS,
+    BrowserTestCase,
+    login,
+    person_logged_in,
+    record_two_runs,
+)
+from lp.testing.layers import LaunchpadFunctionalLayer
+from lp.testing.matchers import HasQueryCount
+from lp.testing.views import create_initialized_view
+
+
+class TestRockRecipeListing(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def assertRockRecipesLink(
+        self, context, link_text, link_has_context=False, **kwargs
+    ):
+        if link_has_context:
+            expected_href = canonical_url(context, view_name="+rock-recipes")
+        else:
+            expected_href = "+rock-recipes"
+        matcher = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "View rock recipes link",
+                "a",
+                text=link_text,
+                attrs={"href": expected_href},
+            )
+        )
+        self.assertThat(self.getViewBrowser(context).contents, Not(matcher))
+        login(ANONYMOUS)
+        with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeRockRecipe(**kwargs)
+            self.factory.makeRockRecipe(**kwargs)
+        self.assertThat(self.getViewBrowser(context).contents, matcher)
+
+    def test_git_repository_links_to_recipes(self):
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        self.assertRockRecipesLink(repository, "2 rock recipes", git_ref=ref)
+
+    def test_git_ref_links_to_recipes(self):
+        self.useFixture(GitHostingFixture())
+        [ref] = self.factory.makeGitRefs()
+        self.assertRockRecipesLink(ref, "2 rock recipes", git_ref=ref)
+
+    def test_person_links_to_recipes(self):
+        person = self.factory.makePerson()
+        self.assertRockRecipesLink(
+            person,
+            "View rock recipes",
+            link_has_context=True,
+            registrant=person,
+            owner=person,
+        )
+
+    def test_project_links_to_recipes(self):
+        project = self.factory.makeProduct()
+        [ref] = self.factory.makeGitRefs(target=project)
+        self.assertRockRecipesLink(
+            project, "View rock recipes", link_has_context=True, git_ref=ref
+        )
+
+    def test_git_repository_recipe_listing(self):
+        # We can see rock recipes for a Git repository.
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeRockRecipe(git_ref=ref)
+        text = self.getMainText(repository, "+rock-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            """
+            Rock recipes for lp:~.*
+            Name            Owner           Registered
+            rock-name.*    Team Name.*     .*""",
+            text,
+        )
+
+    def test_git_ref_recipe_listing(self):
+        # We can see rock recipes for a Git reference.
+        [ref] = self.factory.makeGitRefs()
+        with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeRockRecipe(git_ref=ref)
+        text = self.getMainText(ref, "+rock-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            """
+            Rock recipes for ~.*:.*
+            Name            Owner           Registered
+            rock-name.*    Team Name.*     .*""",
+            text,
+        )
+
+    def test_person_recipe_listing(self):
+        # We can see rock recipes for a person.
+        owner = self.factory.makePerson(displayname="Rock Owner")
+        [ref] = self.factory.makeGitRefs()
+        with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeRockRecipe(
+                registrant=owner,
+                owner=owner,
+                git_ref=ref,
+                date_created=ONE_DAY_AGO,
+            )
+        text = self.getMainText(owner, "+rock-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            """
+            Rock recipes for Rock Owner
+            Name            Source                  Registered
+            rock-name.*    ~.*:.*                  .*""",
+            text,
+        )
+
+    def test_project_recipe_listing(self):
+        # We can see rock recipes for a project.
+        project = self.factory.makeProduct(displayname="Rockable")
+        [ref] = self.factory.makeGitRefs(target=project)
+        with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeRockRecipe(git_ref=ref, date_created=UTC_NOW)
+        text = self.getMainText(project, "+rock-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            """
+            Rock recipes for Rockable
+            Name            Owner           Source          Registered
+            rock-name.*    Team Name.*     ~.*:.*          .*""",
+            text,
+        )
+
+    def assertRockRecipesQueryCount(self, context, item_creator):
+        self.pushConfig("launchpad", default_batch_size=10)
+        recorder1, recorder2 = record_two_runs(
+            lambda: self.getMainText(context, "+rock-recipes"), item_creator, 5
+        )
+        self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
+    def test_git_repository_query_count(self):
+        # The number of queries required to render the list of all rock
+        # recipes for a Git repository is constant in the number of owners
+        # and rock recipes.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=person)
+
+        def create_recipe():
+            with person_logged_in(person):
+                [ref] = self.factory.makeGitRefs(repository=repository)
+                with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeRockRecipe(git_ref=ref)
+
+        self.assertRockRecipesQueryCount(repository, create_recipe)
+
+    def test_git_ref_query_count(self):
+        # The number of queries required to render the list of all rock
+        # recipes for a Git reference is constant in the number of owners
+        # and rock recipes.
+        person = self.factory.makePerson()
+        [ref] = self.factory.makeGitRefs(owner=person)
+
+        def create_recipe():
+            with person_logged_in(person):
+                with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeRockRecipe(git_ref=ref)
+
+        self.assertRockRecipesQueryCount(ref, create_recipe)
+
+    def test_person_query_count(self):
+        # The number of queries required to render the list of all rock
+        # recipes for a person is constant in the number of projects,
+        # sources, and rock recipes.
+        person = self.factory.makePerson()
+
+        def create_recipe():
+            with person_logged_in(person):
+                project = self.factory.makeProduct()
+                [ref] = self.factory.makeGitRefs(owner=person, target=project)
+                with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeRockRecipe(git_ref=ref)
+
+        self.assertRockRecipesQueryCount(person, create_recipe)
+
+    def test_project_query_count(self):
+        # The number of queries required to render the list of all rock
+        # recipes for a person is constant in the number of owners, sources,
+        # and rock recipes.
+        person = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=person)
+
+        def create_recipe():
+            with person_logged_in(person):
+                [ref] = self.factory.makeGitRefs(target=project)
+                with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeRockRecipe(git_ref=ref)
+
+        self.assertRockRecipesQueryCount(project, create_recipe)
+
+    def makeRockRecipesAndMatchers(self, create_recipe, count, start_time):
+        with MemoryFeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}):
+            recipes = [create_recipe() for i in range(count)]
+        for i, recipe in enumerate(recipes):
+            removeSecurityProxy(recipe).date_last_modified = (
+                start_time - timedelta(seconds=i)
+            )
+        return [
+            soupmatchers.Tag(
+                "rock recipe link",
+                "a",
+                text=recipe.name,
+                attrs={
+                    "href": canonical_url(recipe, path_only_if_possible=True)
+                },
+            )
+            for recipe in recipes
+        ]
+
+    def assertBatches(self, context, link_matchers, batched, start, size):
+        view = create_initialized_view(context, "+rock-recipes")
+        listing_tag = soupmatchers.Tag(
+            "rock recipe listing", "table", attrs={"class": "listing sortable"}
+        )
+        batch_nav_tag = soupmatchers.Tag(
+            "batch nav links", "td", attrs={"class": "batch-navigation-links"}
+        )
+        present_links = ([batch_nav_tag] if batched else []) + [
+            matcher
+            for i, matcher in enumerate(link_matchers)
+            if i in range(start, start + size)
+        ]
+        absent_links = ([] if batched else [batch_nav_tag]) + [
+            matcher
+            for i, matcher in enumerate(link_matchers)
+            if i not in range(start, start + size)
+        ]
+        self.assertThat(
+            view.render(),
+            MatchesAll(
+                soupmatchers.HTMLContains(listing_tag, *present_links),
+                Not(soupmatchers.HTMLContains(*absent_links)),
+            ),
+        )
+
+    def test_git_repository_batches_recipes(self):
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        create_recipe = partial(self.factory.makeRockRecipe, git_ref=ref)
+        now = datetime.now(timezone.utc)
+        link_matchers = self.makeRockRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(repository, link_matchers, False, 0, 3)
+        link_matchers.extend(
+            self.makeRockRecipesAndMatchers(
+                create_recipe, 7, now - timedelta(seconds=3)
+            )
+        )
+        self.assertBatches(repository, link_matchers, True, 0, 5)
+
+    def test_git_ref_batches_recipes(self):
+        [ref] = self.factory.makeGitRefs()
+        create_recipe = partial(self.factory.makeRockRecipe, git_ref=ref)
+        now = datetime.now(timezone.utc)
+        link_matchers = self.makeRockRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(ref, link_matchers, False, 0, 3)
+        link_matchers.extend(
+            self.makeRockRecipesAndMatchers(
+                create_recipe, 7, now - timedelta(seconds=3)
+            )
+        )
+        self.assertBatches(ref, link_matchers, True, 0, 5)
+
+    def test_person_batches_recipes(self):
+        owner = self.factory.makePerson()
+        create_recipe = partial(
+            self.factory.makeRockRecipe, registrant=owner, owner=owner
+        )
+        now = datetime.now(timezone.utc)
+        link_matchers = self.makeRockRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(owner, link_matchers, False, 0, 3)
+        link_matchers.extend(
+            self.makeRockRecipesAndMatchers(
+                create_recipe, 7, now - timedelta(seconds=3)
+            )
+        )
+        self.assertBatches(owner, link_matchers, True, 0, 5)
+
+    def test_project_batches_recipes(self):
+        project = self.factory.makeProduct()
+        [ref] = self.factory.makeGitRefs(target=project)
+        create_recipe = partial(self.factory.makeRockRecipe, git_ref=ref)
+        now = datetime.now(timezone.utc)
+        link_matchers = self.makeRockRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(project, link_matchers, False, 0, 3)
+        link_matchers.extend(
+            self.makeRockRecipesAndMatchers(
+                create_recipe, 7, now - timedelta(seconds=3)
+            )
+        )
+        self.assertBatches(project, link_matchers, True, 0, 5)
diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py
index 2abc106..f3dcb96 100644
--- a/lib/lp/rocks/interfaces/rockrecipe.py
+++ b/lib/lp/rocks/interfaces/rockrecipe.py
@@ -493,7 +493,11 @@ class IRockRecipeEditableAttributes(Interface):
     git_path = TextLine(
         title=_("Git branch path"),
         required=False,
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
         readonly=False,
+=======
+        readonly=True,
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
         description=_(
             "The path of the Git branch containing a rockcraft.yaml recipe."
         ),
@@ -649,19 +653,69 @@ class IRockRecipeSet(Interface):
         """Returns the appropriate `IRockRecipe` for the given objects."""
 
 <<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+    def isValidInformationType(information_type, owner, git_ref=None):
+        """Whether the information type context is valid."""
+
+    def preloadDataForRecipes(recipes, user):
+        """Load the data related to a list of rock recipes."""
+
+    def findByGitRepository(repository, paths=None):
 =======
+    def findByPerson(person, visible_by_user=None):
+        """Return all rock recipes relevant to `person`.
+
+        This returns rock recipes for Git branches owned by `person`, or
+        where `person` is the owner of the rock recipe.
+
+        :param person: An `IPerson`.
+        :param visible_by_user: If not None, only return recipes visible by
+            this user; otherwise, only return publicly-visible recipes.
+        """
+
+    def findByProject(project, visible_by_user=None):
+        """Return all rock recipes for the given project.
+
+        :param project: An `IProduct`.
+        :param visible_by_user: If not None, only return recipes visible by
+            this user; otherwise, only return publicly-visible recipes.
+        """
+
+    def findByGitRepository(repository, paths=None, check_permissions=True):
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
+        """Return all rock recipes for the given Git repository.
+
+        :param repository: An `IGitRepository`.
+        :param paths: If not None, only return rock recipes for one of
+            these Git reference paths.
+        """
+
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+    def findByGitRef(ref):
+        """Return all rock recipes for the given Git reference."""
+
+    def findByContext(context, visible_by_user=None, order_by_date=True):
+        """Return all rock recipes for the given context.
+
+        :param context: An `IPerson`, `IProduct`, `IGitRepository`, or
+            `IGitRef`.
+        :param visible_by_user: If not None, only return recipes visible by
+            this user; otherwise, only return publicly-visible recipes.
+        :param order_by_date: If True, order recipes by descending
+            modification date.
+        :raises BadRockRecipeSearchContext: if the context is not
+            understood.
+        """
+
     def exists(owner, project, name):
         """Check to see if a matching rock recipe exists."""
 
->>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     def isValidInformationType(information_type, owner, git_ref=None):
         """Whether the information type context is valid."""
 
     def preloadDataForRecipes(recipes, user):
         """Load the data related to a list of rock recipes."""
 
-<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
-=======
     def getRockcraftYaml(context, logger=None):
         """Fetch a recipe's rockcraft.yaml from code hosting, if possible.
 
@@ -680,14 +734,6 @@ class IRockRecipeSet(Interface):
         """
 
 >>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
-    def findByGitRepository(repository, paths=None):
-        """Return all rock recipes for the given Git repository.
-
-        :param repository: An `IGitRepository`.
-        :param paths: If not None, only return rock recipes for one of
-            these Git reference paths.
-        """
-
     def findByOwner(owner):
         """Return all rock recipes with the given `owner`."""
 
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
index bf575b6..affb756 100644
--- a/lib/lp/rocks/model/rockrecipe.py
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -5,8 +5,8 @@
 
 __all__ = [
     "RockRecipe",
-]
 <<<<<<< lib/lp/rocks/model/rockrecipe.py
+]
 
 from datetime import timezone
 from operator import itemgetter
@@ -21,6 +21,8 @@ from storm.locals import (
     Or,
     Reference,
 =======
+    "get_rock_recipe_privacy_filter",
+]
 from datetime import timezone
 from operator import attrgetter, itemgetter
 
@@ -54,29 +56,47 @@ from lp.app.enums import (
 )
 from lp.buildmaster.enums import BuildStatus
 <<<<<<< lib/lp/rocks/model/rockrecipe.py
+from lp.code.model.gitcollection import GenericGitCollection
+from lp.code.model.gitrepository import GitRepository
+from lp.registry.errors import PrivatePersonLinkageError
+from lp.registry.interfaces.person import IPersonSet, validate_public_person
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.distroseries import DistroSeries
+from lp.registry.model.series import ACTIVE_STATUSES
+from lp.rocks.interfaces.rockrecipe import (
+    ROCK_RECIPE_ALLOW_CREATE,
+    ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
 =======
 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
->>>>>>> lib/lp/rocks/model/rockrecipe.py
+from lp.code.interfaces.gitcollection import (
+    IAllGitRepositories,
+    IGitCollection,
+)
+from lp.code.interfaces.gitref import IGitRef
+from lp.code.interfaces.gitrepository import IGitRepository
 from lp.code.model.gitcollection import GenericGitCollection
+from lp.code.model.gitref import GitRef
 from lp.code.model.gitrepository import GitRepository
 from lp.registry.errors import PrivatePersonLinkageError
-from lp.registry.interfaces.person import IPersonSet, validate_public_person
+from lp.registry.interfaces.person import (
+    IPerson,
+    IPersonSet,
+    validate_public_person,
+)
+from lp.registry.interfaces.product import IProduct
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
+from lp.registry.model.product import Product
 from lp.registry.model.series import ACTIVE_STATUSES
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
-from lp.rocks.interfaces.rockrecipe import (
-    ROCK_RECIPE_ALLOW_CREATE,
-    ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
-=======
 from lp.rocks.adapters.buildarch import determine_instances_to_build
 from lp.rocks.interfaces.rockrecipe import (
     ROCK_RECIPE_ALLOW_CREATE,
     ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
+    BadRockRecipeSearchContext,
     CannotFetchRockcraftYaml,
     CannotParseRockcraftYaml,
 >>>>>>> lib/lp/rocks/model/rockrecipe.py
@@ -351,14 +371,27 @@ class RockRecipe(StormBase):
         """See `IRockRecipe`."""
         return self.information_type not in PUBLIC_INFORMATION_TYPES
 
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
     @property
     def git_ref(self):
         """See `IRockRecipe`."""
+=======
+    @cachedproperty
+    def _git_ref(self):
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
         if self.git_repository is not None:
             return self.git_repository.getRefByPath(self.git_path)
         else:
             return None
 
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
+=======
+    @property
+    def git_ref(self):
+        """See `IRockRecipe`."""
+        return self._git_ref
+
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
     @git_ref.setter
     def git_ref(self, value):
         """See `IRockRecipe`."""
@@ -368,16 +401,17 @@ class RockRecipe(StormBase):
         else:
             self.git_repository = None
             self.git_path = None
-
-    @property
 <<<<<<< lib/lp/rocks/model/rockrecipe.py
 =======
+        get_property_cache(self)._git_ref = value
+
+    @property
     def source(self):
         """See `IRockRecipe`."""
         return self.git_ref
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
 
     @property
->>>>>>> lib/lp/rocks/model/rockrecipe.py
     def store_channels(self):
         """See `IRockRecipe`."""
         return self._store_channels or []
@@ -397,9 +431,23 @@ class RockRecipe(StormBase):
         """See `IRockRecipe`."""
         if self.information_type in PUBLIC_INFORMATION_TYPES:
             return True
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
         # XXX jugmac00 2024-08-29: Finish implementing this once we have
         # more privacy infrastructure.
         return False
+=======
+        if user is None:
+            return False
+        return (
+            not IStore(RockRecipe)
+            .find(
+                RockRecipe,
+                RockRecipe.id == self.id,
+                get_rock_recipe_privacy_filter(user),
+            )
+            .is_empty()
+        )
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
 
     def _isBuildableArchitectureAllowed(self, das):
         """Check whether we may build for a buildable `DistroArchSeries`.
@@ -834,6 +882,99 @@ class RockRecipeSet:
             raise NoSuchRockRecipe(name)
         return recipe
 
+    def _getRecipesFromCollection(
+        self, collection, owner=None, visible_by_user=None
+    ):
+        id_column = RockRecipe.git_repository_id
+        ids = collection.getRepositoryIds()
+        expressions = [id_column.is_in(ids._get_select())]
+        if owner is not None:
+            expressions.append(RockRecipe.owner == owner)
+        expressions.append(get_rock_recipe_privacy_filter(visible_by_user))
+        return IStore(RockRecipe).find(RockRecipe, *expressions)
+
+    def findByPerson(self, person, visible_by_user=None):
+        """See `IRockRecipeSet`."""
+
+        def _getRecipes(collection):
+            collection = collection.visibleByUser(visible_by_user)
+            owned = self._getRecipesFromCollection(
+                collection.ownedBy(person), visible_by_user=visible_by_user
+            )
+            packaged = self._getRecipesFromCollection(
+                collection, owner=person, visible_by_user=visible_by_user
+            )
+            return owned.union(packaged)
+
+        git_collection = removeSecurityProxy(getUtility(IAllGitRepositories))
+        git_recipes = _getRecipes(git_collection)
+        return git_recipes
+
+    def findByProject(self, project, visible_by_user=None):
+        """See `IRockRecipeSet`."""
+
+        def _getRecipes(collection):
+            return self._getRecipesFromCollection(
+                collection.visibleByUser(visible_by_user),
+                visible_by_user=visible_by_user,
+            )
+
+        recipes_for_project = IStore(RockRecipe).find(
+            RockRecipe,
+            RockRecipe.project == project,
+            get_rock_recipe_privacy_filter(visible_by_user),
+        )
+        git_collection = removeSecurityProxy(IGitCollection(project))
+        return recipes_for_project.union(_getRecipes(git_collection))
+
+    def findByGitRepository(
+        self,
+        repository,
+        paths=None,
+        visible_by_user=None,
+        check_permissions=True,
+    ):
+        """See `IRockRecipeSet`."""
+        clauses = [RockRecipe.git_repository == repository]
+        if paths is not None:
+            clauses.append(RockRecipe.git_path.is_in(paths))
+        if check_permissions:
+            clauses.append(get_rock_recipe_privacy_filter(visible_by_user))
+        return IStore(RockRecipe).find(RockRecipe, *clauses)
+
+    def findByGitRef(self, ref, visible_by_user=None):
+        """See `IRockRecipeSet`."""
+        return IStore(RockRecipe).find(
+            RockRecipe,
+            RockRecipe.git_repository == ref.repository,
+            RockRecipe.git_path == ref.path,
+            get_rock_recipe_privacy_filter(visible_by_user),
+        )
+
+    def findByContext(self, context, visible_by_user=None, order_by_date=True):
+        """See `IRockRecipeSet`."""
+        if IPerson.providedBy(context):
+            recipes = self.findByPerson(
+                context, visible_by_user=visible_by_user
+            )
+        elif IProduct.providedBy(context):
+            recipes = self.findByProject(
+                context, visible_by_user=visible_by_user
+            )
+        elif IGitRepository.providedBy(context):
+            recipes = self.findByGitRepository(
+                context, visible_by_user=visible_by_user
+            )
+        elif IGitRef.providedBy(context):
+            recipes = self.findByGitRef(
+                context, visible_by_user=visible_by_user
+            )
+        else:
+            raise BadRockRecipeSearchContext(context)
+        if order_by_date:
+            recipes = recipes.order_by(Desc(RockRecipe.date_last_modified))
+        return recipes
+
 >>>>>>> lib/lp/rocks/model/rockrecipe.py
     def isValidInformationType(self, information_type, owner, git_ref=None):
         """See `IRockRecipeSet`."""
@@ -858,6 +999,11 @@ class RockRecipeSet:
         """See `IRockRecipeSet`."""
         recipes = [removeSecurityProxy(recipe) for recipe in recipes]
 
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
+=======
+        load_related(Product, recipes, ["project_id"])
+
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
         person_ids = set()
         for recipe in recipes:
             person_ids.add(recipe.registrant_id)
@@ -869,6 +1015,17 @@ class RockRecipeSet:
         if repositories:
             GenericGitCollection.preloadDataForRepositories(repositories)
 
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
+=======
+        git_refs = GitRef.findByReposAndPaths(
+            [(recipe.git_repository, recipe.git_path) for recipe in recipes]
+        )
+        for recipe in recipes:
+            git_ref = git_refs.get((recipe.git_repository, recipe.git_path))
+            if git_ref is not None:
+                get_property_cache(recipe)._git_ref = git_ref
+
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
         # Add repository owners to the list of pre-loaded persons. We need
         # the target repository owner as well, since repository unique names
         # aren't trigger-maintained.
@@ -881,6 +1038,14 @@ class RockRecipeSet:
         )
 
 <<<<<<< lib/lp/rocks/model/rockrecipe.py
+    def findByGitRepository(self, repository, paths=None):
+        """See `IRockRecipeSet`."""
+        clauses = [RockRecipe.git_repository == repository]
+        if paths is not None:
+            clauses.append(RockRecipe.git_path.is_in(paths))
+        # XXX jugmac00 2024-08-29: Check permissions once we have some
+        # privacy infrastructure.
+        return IStore(RockRecipe).find(RockRecipe, *clauses)
 =======
     def getRockcraftYaml(self, context, logger=None):
         """See `IRockRecipeSet`."""
@@ -928,16 +1093,7 @@ class RockRecipeSet:
             )
 
         return rockcraft_data
-
 >>>>>>> lib/lp/rocks/model/rockrecipe.py
-    def findByGitRepository(self, repository, paths=None):
-        """See `IRockRecipeSet`."""
-        clauses = [RockRecipe.git_repository == repository]
-        if paths is not None:
-            clauses.append(RockRecipe.git_path.is_in(paths))
-        # XXX jugmac00 2024-08-29: Check permissions once we have some
-        # privacy infrastructure.
-        return IStore(RockRecipe).find(RockRecipe, *clauses)
 
     def findByOwner(self, owner):
         """See `ICharmRecipeSet`."""
@@ -946,9 +1102,24 @@ class RockRecipeSet:
     def detachFromGitRepository(self, repository):
 <<<<<<< lib/lp/rocks/model/rockrecipe.py
         """See `IRockRecipeSet`."""
+        self.findByGitRepository(repository).set(
+            git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
+        )
 =======
         """See `ICharmRecipeSet`."""
->>>>>>> lib/lp/rocks/model/rockrecipe.py
-        self.findByGitRepository(repository).set(
+        recipes = self.findByGitRepository(repository)
+        for recipe in recipes:
+            get_property_cache(recipe)._git_ref = None
+        recipes.set(
             git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
         )
+
+
+def get_rock_recipe_privacy_filter(user):
+    """Return a Storm query filter to find rock recipes visible to `user`."""
+    public_filter = RockRecipe.information_type.is_in(PUBLIC_INFORMATION_TYPES)
+
+    # XXX jugmac00 2024-09-016: Flesh this out once we have more privacy
+    # infrastructure.
+    return [public_filter]
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
diff --git a/lib/lp/rocks/model/rockrecipebuild.py b/lib/lp/rocks/model/rockrecipebuild.py
index e6025e5..5befbd9 100644
--- a/lib/lp/rocks/model/rockrecipebuild.py
+++ b/lib/lp/rocks/model/rockrecipebuild.py
@@ -28,6 +28,11 @@ from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
 from lp.buildmaster.model.packagebuild import PackageBuildMixin
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
+<<<<<<< lib/lp/rocks/model/rockrecipebuild.py
+=======
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.distroseries import DistroSeries
+>>>>>>> lib/lp/rocks/model/rockrecipebuild.py
 from lp.registry.model.person import Person
 from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
 from lp.rocks.interfaces.rockrecipebuild import (
@@ -46,6 +51,10 @@ from lp.services.database.stormbase import StormBase
 from lp.services.librarian.model import LibraryFileAlias, LibraryFileContent
 from lp.services.propertycache import cachedproperty, get_property_cache
 from lp.services.webapp.snapshot import notify_modified
+<<<<<<< lib/lp/rocks/model/rockrecipebuild.py
+=======
+from lp.soyuz.model.distroarchseries import DistroArchSeries
+>>>>>>> lib/lp/rocks/model/rockrecipebuild.py
 
 
 @implementer(IRockRecipeBuild)
@@ -408,6 +417,16 @@ class RockRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
         load_related(Person, builds, ["requester_id"])
         lfas = load_related(LibraryFileAlias, builds, ["log_id"])
         load_related(LibraryFileContent, lfas, ["contentID"])
+<<<<<<< lib/lp/rocks/model/rockrecipebuild.py
+=======
+        distroarchserieses = load_related(
+            DistroArchSeries, builds, ["distro_arch_series_id"]
+        )
+        distroserieses = load_related(
+            DistroSeries, distroarchserieses, ["distroseries_id"]
+        )
+        load_related(Distribution, distroserieses, ["distribution_id"])
+>>>>>>> lib/lp/rocks/model/rockrecipebuild.py
         recipes = load_related(RockRecipe, builds, ["recipe_id"])
         getUtility(IRockRecipeSet).preloadDataForRecipes(recipes)
 
diff --git a/lib/lp/rocks/templates/rockrecipe-listing.pt b/lib/lp/rocks/templates/rockrecipe-listing.pt
new file mode 100644
index 0000000..f6ea1b1
--- /dev/null
+++ b/lib/lp/rocks/templates/rockrecipe-listing.pt
@@ -0,0 +1,46 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+
+<body>
+
+  <div metal:fill-slot="main">
+
+    <tal:navigation
+        condition="view/batchnav/has_multiple_pages"
+        replace="structure view/batchnav/@@+navigation-links-upper" />
+    <table id="rock-recipe-table" class="listing sortable">
+      <thead>
+        <tr>
+          <th colspan="2">Name</th>
+          <th tal:condition="view/owner_enabled">Owner</th>
+          <th tal:condition="view/source_enabled">Source</th>
+          <th>Registered</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tal:recipes repeat="recipe view/batchnav/currentBatch">
+          <tr>
+            <td colspan="2">
+              <a tal:attributes="href recipe/fmt:url" tal:content="recipe/name" />
+            </td>
+            <td tal:condition="view/owner_enabled"
+                tal:content="structure recipe/owner/fmt:link" />
+            <td tal:condition="view/source_enabled"
+                tal:content="structure recipe/source/fmt:link" />
+            <td tal:content="recipe/date_created/fmt:datetime" />
+          </tr>
+        </tal:recipes>
+      </tbody>
+    </table>
+    <tal:navigation
+        condition="view/batchnav/has_multiple_pages"
+        replace="structure view/batchnav/@@+navigation-links-lower" />
+
+  </div>
+</body>
+</html>
diff --git a/lib/lp/rocks/templates/rockrecipe-macros.pt b/lib/lp/rocks/templates/rockrecipe-macros.pt
new file mode 100644
index 0000000..d91afd1
--- /dev/null
+++ b/lib/lp/rocks/templates/rockrecipe-macros.pt
@@ -0,0 +1,22 @@
+<tal:root
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  omit-tag="">
+
+<div
+  metal:define-macro="related-rock-recipes"
+  tal:define="context_menu context/menu:context"
+  id="related-rock-recipes">
+
+  <h3>Related rock recipes</h3>
+
+  <div id="rock-recipe-links" class="actions">
+    <div id="rock-recipe-summary">
+      <tal:rock_recipes replace="structure view/rock_recipes_link" />
+    </div>
+  </div>
+
+</div>
+
+</tal:root>
diff --git a/lib/lp/rocks/tests/test_rockrecipe.py b/lib/lp/rocks/tests/test_rockrecipe.py
index 583f155..9abf788 100644
--- a/lib/lp/rocks/tests/test_rockrecipe.py
+++ b/lib/lp/rocks/tests/test_rockrecipe.py
@@ -33,13 +33,16 @@ from lp.buildmaster.interfaces.processor import (
 )
 <<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
 from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.rocks.interfaces.rockrecipe import (
+    ROCK_RECIPE_ALLOW_CREATE,
 =======
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.tests.helpers import GitHostingFixture
->>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
 from lp.rocks.interfaces.rockrecipe import (
     ROCK_RECIPE_ALLOW_CREATE,
+    BadRockRecipeSearchContext,
+>>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     IRockRecipe,
     IRockRecipeSet,
     NoSourceForRockRecipe,
@@ -761,6 +764,48 @@ class TestRockRecipeSet(TestCaseWithFactory):
             getUtility(IRockRecipeSet).getByName(owner, project, "proj-rock"),
         )
 
+<<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
+=======
+    def test_findByPerson(self):
+        # IRockRecipeSet.findByPerson returns all rock recipes with the
+        # given owner or based on repositories with the given owner.
+        owners = [self.factory.makePerson() for i in range(2)]
+        recipes = []
+        for owner in owners:
+            recipes.append(
+                self.factory.makeRockRecipe(registrant=owner, owner=owner)
+            )
+            [ref] = self.factory.makeGitRefs(owner=owner)
+            recipes.append(self.factory.makeRockRecipe(git_ref=ref))
+        recipe_set = getUtility(IRockRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByPerson(owners[0])
+        )
+        self.assertContentEqual(
+            recipes[2:], recipe_set.findByPerson(owners[1])
+        )
+
+    def test_findByProject(self):
+        # IRockRecipeSet.findByProject returns all rock recipes based on
+        # repositories for the given project, and rock recipes associated
+        # directly with the project.
+        projects = [self.factory.makeProduct() for i in range(2)]
+        recipes = []
+        for project in projects:
+            [ref] = self.factory.makeGitRefs(target=project)
+            recipes.append(self.factory.makeRockRecipe(git_ref=ref))
+            recipes.append(self.factory.makeRockRecipe(project=project))
+        [ref] = self.factory.makeGitRefs(target=None)
+        recipes.append(self.factory.makeRockRecipe(git_ref=ref))
+        recipe_set = getUtility(IRockRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByProject(projects[0])
+        )
+        self.assertContentEqual(
+            recipes[2:4], recipe_set.findByProject(projects[1])
+        )
+
+>>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     def test_findByGitRepository(self):
         # IRockRecipeSet.findByGitRepository returns all rock recipes with
         # the given Git repository.
@@ -819,6 +864,73 @@ class TestRockRecipeSet(TestCaseWithFactory):
         self.assertContentEqual(recipes[:2], recipe_set.findByOwner(owners[0]))
         self.assertContentEqual(recipes[2:], recipe_set.findByOwner(owners[1]))
 
+<<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
+=======
+    def test_findByGitRef(self):
+        # IRockRecipeSet.findByGitRef returns all rock recipes with the
+        # given Git reference.
+        repositories = [self.factory.makeGitRepository() for i in range(2)]
+        refs = []
+        recipes = []
+        for _ in repositories:
+            refs.extend(
+                self.factory.makeGitRefs(
+                    paths=["refs/heads/master", "refs/heads/other"]
+                )
+            )
+            recipes.append(self.factory.makeRockRecipe(git_ref=refs[-2]))
+            recipes.append(self.factory.makeRockRecipe(git_ref=refs[-1]))
+        recipe_set = getUtility(IRockRecipeSet)
+        for ref, recipe in zip(refs, recipes):
+            self.assertContentEqual([recipe], recipe_set.findByGitRef(ref))
+
+    def test_findByContext(self):
+        # IRockRecipeSet.findByContext returns all rock recipes with the
+        # given context.
+        person = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=project
+        )
+        refs = self.factory.makeGitRefs(
+            repository=repository,
+            paths=["refs/heads/master", "refs/heads/other"],
+        )
+        other_repository = self.factory.makeGitRepository()
+        other_refs = self.factory.makeGitRefs(
+            repository=other_repository,
+            paths=["refs/heads/master", "refs/heads/other"],
+        )
+        recipes = []
+        recipes.append(self.factory.makeRockRecipe(git_ref=refs[0]))
+        recipes.append(self.factory.makeRockRecipe(git_ref=refs[1]))
+        recipes.append(
+            self.factory.makeRockRecipe(
+                registrant=person, owner=person, git_ref=other_refs[0]
+            )
+        )
+        recipes.append(
+            self.factory.makeRockRecipe(project=project, git_ref=other_refs[1])
+        )
+        recipe_set = getUtility(IRockRecipeSet)
+        self.assertContentEqual(recipes[:3], recipe_set.findByContext(person))
+        self.assertContentEqual(
+            [recipes[0], recipes[1], recipes[3]],
+            recipe_set.findByContext(project),
+        )
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByContext(repository)
+        )
+        self.assertContentEqual(
+            [recipes[0]], recipe_set.findByContext(refs[0])
+        )
+        self.assertRaises(
+            BadRockRecipeSearchContext,
+            recipe_set.findByContext,
+            self.factory.makeDistribution(),
+        )
+
+>>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     def test_detachFromGitRepository(self):
         # IRockRecipeSet.detachFromGitRepository clears the given Git
         # repository from all rock recipes.
diff --git a/lib/lp/soyuz/templates/person-portlet-ppas.pt b/lib/lp/soyuz/templates/person-portlet-ppas.pt
index a97a9d5..bc79ed6 100644
--- a/lib/lp/soyuz/templates/person-portlet-ppas.pt
+++ b/lib/lp/soyuz/templates/person-portlet-ppas.pt
@@ -35,11 +35,13 @@
       tal:define="recipes_link context/menu:overview/view_recipes;
                   snaps_link context/menu:overview/view_snaps;
                   oci_recipes_link context/menu:overview/view_oci_recipes;
-                  charm_recipes_link context/menu:overview/view_charm_recipes"
+                  charm_recipes_link context/menu:overview/view_charm_recipes;
+                  rock_recipes_link context/menu:overview/view_rock_recipes;"
       tal:condition="python: recipes_link.enabled
                              or snaps_link.enabled
                              or oci_recipes_link.enabled
-                             or charm_recipes_link.enabled">
+                             or charm_recipes_link.enabled
+                             or rock_recipes_link.enabled">
     <li tal:condition="recipes_link/enabled">
       <a tal:replace="structure recipes_link/fmt:link" />
     </li>
@@ -52,5 +54,8 @@
     <li tal:condition="charm_recipes_link/enabled">
       <a tal:replace="structure charm_recipes_link/fmt:link" />
     </li>
+    <li tal:condition="rock_recipes_link/enabled">
+      <a tal:replace="structure rock_recipes_link/fmt:link" />
+    </li>
   </ul>
 </tal:root>

Follow ups