← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-recipe-listing-views into launchpad:master

 

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

Commit message:
Add charm recipe listing views

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403878
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-listing-views into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipelisting.py b/lib/lp/charms/browser/charmrecipelisting.py
new file mode 100644
index 0000000..d9b3edd
--- /dev/null
+++ b/lib/lp/charms/browser/charmrecipelisting.py
@@ -0,0 +1,73 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Base class view for charm recipe listings."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+__all__ = [
+    "GitCharmRecipeListingView",
+    "PersonCharmRecipeListingView",
+    "ProjectCharmRecipeListingView",
+    ]
+
+from functools import partial
+
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
+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 CharmRecipeListingView(LaunchpadView, FeedsMixin):
+
+    feed_types = ()
+
+    source_enabled = True
+    owner_enabled = True
+
+    @property
+    def page_title(self):
+        return "Charm recipes"
+
+    @property
+    def label(self):
+        return "Charm recipes for %(displayname)s" % {
+            "displayname": self.context.displayname}
+
+    def initialize(self):
+        super(CharmRecipeListingView, self).initialize()
+        recipes = getUtility(ICharmRecipeSet).findByContext(
+            self.context, visible_by_user=self.user)
+        loader = partial(
+            getUtility(ICharmRecipeSet).preloadDataForRecipes, user=self.user)
+        self.recipes = DecoratedResultSet(recipes, pre_iter_hook=loader)
+
+    @cachedproperty
+    def batchnav(self):
+        return BatchNavigator(self.recipes, self.request)
+
+
+class GitCharmRecipeListingView(CharmRecipeListingView):
+
+    source_enabled = False
+
+    @property
+    def label(self):
+        return "Charm recipes for %(display_name)s" % {
+            "display_name": self.context.display_name}
+
+
+class PersonCharmRecipeListingView(CharmRecipeListingView):
+
+    owner_enabled = False
+
+
+class ProjectCharmRecipeListingView(CharmRecipeListingView):
+    pass
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 0475288..3c54b6e 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -76,5 +76,36 @@
             for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
             factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
             permission="zope.Public" />
+
+        <browser:page
+            for="*"
+            class="lp.app.browser.launchpad.Macro"
+            permission="zope.Public"
+            name="+charm-recipe-macros"
+            template="../templates/charmrecipe-macros.pt" />
+        <browser:page
+            for="lp.code.interfaces.gitrepository.IGitRepository"
+            class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
+            permission="launchpad.View"
+            name="+charm-recipes"
+            template="../templates/charmrecipe-listing.pt" />
+        <browser:page
+            for="lp.code.interfaces.gitref.IGitRef"
+            class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
+            permission="launchpad.View"
+            name="+charm-recipes"
+            template="../templates/charmrecipe-listing.pt" />
+        <browser:page
+            for="lp.registry.interfaces.person.IPerson"
+            class="lp.charms.browser.charmrecipelisting.PersonCharmRecipeListingView"
+            permission="launchpad.View"
+            name="+charm-recipes"
+            template="../templates/charmrecipe-listing.pt" />
+        <browser:page
+            for="lp.registry.interfaces.product.IProduct"
+            class="lp.charms.browser.charmrecipelisting.ProjectCharmRecipeListingView"
+            permission="launchpad.View"
+            name="+charm-recipes"
+            template="../templates/charmrecipe-listing.pt" />
     </facet>
 </configure>
diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py
new file mode 100644
index 0000000..5c395b5
--- /dev/null
+++ b/lib/lp/charms/browser/hascharmrecipes.py
@@ -0,0 +1,64 @@
+# Copyright 2021 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 charm recipes."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "HasCharmRecipesMenuMixin",
+    "HasCharmRecipesViewMixin",
+    ]
+
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.webapp import (
+    canonical_url,
+    Link,
+    )
+from lp.services.webapp.escaping import structured
+
+
+class HasCharmRecipesMenuMixin:
+    """A mixin for context menus for objects that have charm recipes."""
+
+    def view_charm_recipes(self):
+        text = "View charm recipes"
+        enabled = not getUtility(ICharmRecipeSet).findByContext(
+            self.context, visible_by_user=self.user).is_empty()
+        return Link("+charm-recipes", text, icon="info", enabled=enabled)
+
+
+class HasCharmRecipesViewMixin:
+    """A view mixin for objects that have charm recipes."""
+
+    @property
+    def charm_recipes(self):
+        return getUtility(ICharmRecipeSet).findByContext(
+            self.context, visible_by_user=self.user)
+
+    @property
+    def charm_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/charms/browser/tests/test_charmrecipelisting.py b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
new file mode 100644
index 0000000..5bfd233
--- /dev/null
+++ b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
@@ -0,0 +1,276 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test charm recipe listings."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+from functools import partial
+
+import pytz
+import soupmatchers
+from testtools.matchers import (
+    MatchesAll,
+    Not,
+    )
+from zope.security.proxy import removeSecurityProxy
+
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.code.tests.helpers import GitHostingFixture
+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 TestCharmRecipeListing(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def assertCharmRecipesLink(self, context, link_text,
+                               link_has_context=False, **kwargs):
+        if link_has_context:
+            expected_href = canonical_url(context, view_name="+charm-recipes")
+        else:
+            expected_href = "+charm-recipes"
+        matcher = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "View charm recipes link", "a", text=link_text,
+                attrs={"href": expected_href}))
+        self.assertThat(self.getViewBrowser(context).contents, Not(matcher))
+        login(ANONYMOUS)
+        with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeCharmRecipe(**kwargs)
+            self.factory.makeCharmRecipe(**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.assertCharmRecipesLink(repository, "2 charm recipes", git_ref=ref)
+
+    def test_git_ref_links_to_recipes(self):
+        self.useFixture(GitHostingFixture())
+        [ref] = self.factory.makeGitRefs()
+        self.assertCharmRecipesLink(ref, "2 charm recipes", git_ref=ref)
+
+    def test_person_links_to_recipes(self):
+        person = self.factory.makePerson()
+        self.assertCharmRecipesLink(
+            person, "View charm 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.assertCharmRecipesLink(
+            project, "View charm recipes", link_has_context=True, git_ref=ref)
+
+    def test_git_repository_recipe_listing(self):
+        # We can see charm recipes for a Git repository.
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeCharmRecipe(git_ref=ref)
+        text = self.getMainText(repository, "+charm-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace("""
+            Charm recipes for lp:~.*
+            Name            Owner           Registered
+            charm-name.*    Team Name.*     .*""", text)
+
+    def test_git_ref_recipe_listing(self):
+        # We can see charm recipes for a Git reference.
+        [ref] = self.factory.makeGitRefs()
+        with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeCharmRecipe(git_ref=ref)
+        text = self.getMainText(ref, "+charm-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace("""
+            Charm recipes for ~.*:.*
+            Name            Owner           Registered
+            charm-name.*    Team Name.*     .*""", text)
+
+    def test_person_recipe_listing(self):
+        # We can see charm recipes for a person.
+        owner = self.factory.makePerson(displayname="Charm Owner")
+        [ref] = self.factory.makeGitRefs()
+        with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeCharmRecipe(
+                registrant=owner, owner=owner, git_ref=ref,
+                date_created=ONE_DAY_AGO)
+        text = self.getMainText(owner, "+charm-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace("""
+            Charm recipes for Charm Owner
+            Name            Source                  Registered
+            charm-name.*    ~.*:.*                  .*""", text)
+
+    def test_project_recipe_listing(self):
+        # We can see charm recipes for a project.
+        project = self.factory.makeProduct(displayname="Charmable")
+        [ref] = self.factory.makeGitRefs(target=project)
+        with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            self.factory.makeCharmRecipe(git_ref=ref, date_created=UTC_NOW)
+        text = self.getMainText(project, "+charm-recipes")
+        self.assertTextMatchesExpressionIgnoreWhitespace("""
+            Charm recipes for Charmable
+            Name            Owner           Source          Registered
+            charm-name.*    Team Name.*     ~.*:.*          .*""", text)
+
+    def assertCharmRecipesQueryCount(self, context, item_creator):
+        self.pushConfig("launchpad", default_batch_size=10)
+        recorder1, recorder2 = record_two_runs(
+            lambda: self.getMainText(context, "+charm-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 charm
+        # recipes for a Git repository is constant in the number of owners
+        # and charm 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({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeCharmRecipe(git_ref=ref)
+
+        self.assertCharmRecipesQueryCount(repository, create_recipe)
+
+    def test_git_ref_query_count(self):
+        # The number of queries required to render the list of all charm
+        # recipes for a Git reference is constant in the number of owners
+        # and charm recipes.
+        person = self.factory.makePerson()
+        [ref] = self.factory.makeGitRefs(owner=person)
+
+        def create_recipe():
+            with person_logged_in(person):
+                with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeCharmRecipe(git_ref=ref)
+
+        self.assertCharmRecipesQueryCount(ref, create_recipe)
+
+    def test_person_query_count(self):
+        # The number of queries required to render the list of all charm
+        # recipes for a person is constant in the number of projects,
+        # sources, and charm 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({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeCharmRecipe(git_ref=ref)
+
+        self.assertCharmRecipesQueryCount(person, create_recipe)
+
+    def test_project_query_count(self):
+        # The number of queries required to render the list of all charm
+        # recipes for a person is constant in the number of owners, sources,
+        # and charm 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({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+                    self.factory.makeCharmRecipe(git_ref=ref)
+
+        self.assertCharmRecipesQueryCount(project, create_recipe)
+
+    def makeCharmRecipesAndMatchers(self, create_recipe, count, start_time):
+        with MemoryFeatureFixture({CHARM_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(
+                "charm 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, "+charm-recipes")
+        listing_tag = soupmatchers.Tag(
+            "charm 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.makeCharmRecipe, git_ref=ref)
+        now = datetime.now(pytz.UTC)
+        link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(repository, link_matchers, False, 0, 3)
+        link_matchers.extend(self.makeCharmRecipesAndMatchers(
+            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.makeCharmRecipe, git_ref=ref)
+        now = datetime.now(pytz.UTC)
+        link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(ref, link_matchers, False, 0, 3)
+        link_matchers.extend(self.makeCharmRecipesAndMatchers(
+            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.makeCharmRecipe, registrant=owner, owner=owner)
+        now = datetime.now(pytz.UTC)
+        link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(owner, link_matchers, False, 0, 3)
+        link_matchers.extend(self.makeCharmRecipesAndMatchers(
+            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.makeCharmRecipe, git_ref=ref)
+        now = datetime.now(pytz.UTC)
+        link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+        self.assertBatches(project, link_matchers, False, 0, 3)
+        link_matchers.extend(self.makeCharmRecipesAndMatchers(
+            create_recipe, 7, now - timedelta(seconds=3)))
+        self.assertBatches(project, link_matchers, True, 0, 5)
diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
new file mode 100644
index 0000000..6374893
--- /dev/null
+++ b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
@@ -0,0 +1,86 @@
+# Copyright 2021 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 charm recipes."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from testscenarios import (
+    load_tests_apply_scenarios,
+    WithScenarios,
+    )
+
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.code.interfaces.gitrepository import IGitRepository
+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 TestHasCharmRecipesView(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(TestHasCharmRecipesView, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+    def makeCharmRecipe(self, context):
+        if IGitRepository.providedBy(context):
+            [context] = self.factory.makeGitRefs(repository=context)
+        return self.factory.makeCharmRecipe(git_ref=context)
+
+    def test_charm_recipes_link_no_recipes(self):
+        # An object with no charm recipes does not show a charm recipes link.
+        context = self.context_factory(self)
+        view = create_initialized_view(context, "+index")
+        self.assertEqual(
+            "No charm recipes using this %s." % self.context_type,
+            view.charm_recipes_link)
+
+    def test_charm_recipes_link_one_recipe(self):
+        # An object with one charm recipe shows a link to that recipe.
+        context = self.context_factory(self)
+        recipe = self.makeCharmRecipe(context)
+        view = create_initialized_view(context, "+index")
+        expected_link = (
+            '<a href="%s">1 charm recipe</a> using this %s.' %
+            (canonical_url(recipe), self.context_type))
+        self.assertEqual(expected_link, view.charm_recipes_link)
+
+    def test_charm_recipes_link_more_recipes(self):
+        # An object with more than one charm recipe shows a link to a listing.
+        context = self.context_factory(self)
+        self.makeCharmRecipe(context)
+        self.makeCharmRecipe(context)
+        view = create_initialized_view(context, "+index")
+        expected_link = (
+            '<a href="+charm-recipes">2 charm recipes</a> using this %s.' %
+            self.context_type)
+        self.assertEqual(expected_link, view.charm_recipes_link)
+
+
+load_tests = load_tests_apply_scenarios
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 6b70d1b..c972097 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -412,7 +412,7 @@ class ICharmRecipeEditableAttributes(Interface):
             "recipe."))
 
     git_path = TextLine(
-        title=_("Git branch path"), required=False, readonly=False,
+        title=_("Git branch path"), required=False, readonly=True,
         description=_(
             "The path of the Git branch containing a charmcraft.yaml "
             "recipe."))
@@ -513,6 +513,49 @@ class ICharmRecipeSet(Interface):
     def getByName(owner, project, name):
         """Returns the appropriate `ICharmRecipe` for the given objects."""
 
+    def findByPerson(person, visible_by_user=None):
+        """Return all charm recipes relevant to `person`.
+
+        This returns charm recipes for Git branches owned by `person`, or
+        where `person` is the owner of the charm 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 charm 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):
+        """Return all charm recipes for the given Git repository.
+
+        :param repository: An `IGitRepository`.
+        :param paths: If not None, only return charm recipes for one of
+            these Git reference paths.
+        """
+
+    def findByGitRef(ref):
+        """Return all charm recipes for the given Git reference."""
+
+    def findByContext(context, visible_by_user=None, order_by_date=True):
+        """Return all charm 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 BadCharmRecipeSearchContext: if the context is not
+            understood.
+        """
+
     def isValidInformationType(information_type, owner, git_ref=None):
         """Whether the information type context is valid."""
 
@@ -536,14 +579,6 @@ class ICharmRecipeSet(Interface):
             cannot be parsed.
         """
 
-    def findByGitRepository(repository, paths=None):
-        """Return all charm recipes for the given Git repository.
-
-        :param repository: An `IGitRepository`.
-        :param paths: If not None, only return charm recipes for one of
-            these Git reference paths.
-        """
-
     def detachFromGitRepository(repository):
         """Detach all charm recipes from the given Git repository.
 
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index da24bf6..d9c9b5c 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 __all__ = [
     "CharmRecipe",
+    "get_charm_recipe_privacy_filter",
     ]
 
 from operator import (
@@ -47,6 +48,7 @@ from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
 from lp.buildmaster.model.builder import Builder
 from lp.charms.adapters.buildarch import determine_instances_to_build
 from lp.charms.interfaces.charmrecipe import (
+    BadCharmRecipeSearchContext,
     CannotFetchCharmcraftYaml,
     CannotParseCharmcraftYaml,
     CHARM_RECIPE_ALLOW_CREATE,
@@ -75,16 +77,26 @@ from lp.code.errors import (
     GitRepositoryBlobNotFound,
     GitRepositoryScanFault,
     )
+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.distribution import IDistributionSet
 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
 from lp.services.database.bulk import load_related
 from lp.services.database.constants import (
@@ -314,14 +326,18 @@ class CharmRecipe(StormBase):
         """See `ICharmRecipe`."""
         return self.information_type not in PUBLIC_INFORMATION_TYPES
 
-    @property
-    def git_ref(self):
-        """See `ICharmRecipe`."""
+    @cachedproperty
+    def _git_ref(self):
         if self.git_repository is not None:
             return self.git_repository.getRefByPath(self.git_path)
         else:
             return None
 
+    @property
+    def git_ref(self):
+        """See `ICharmRecipe`."""
+        return self._git_ref
+
     @git_ref.setter
     def git_ref(self, value):
         """See `ICharmRecipe`."""
@@ -331,6 +347,7 @@ class CharmRecipe(StormBase):
         else:
             self.git_repository = None
             self.git_path = None
+        get_property_cache(self)._git_ref = value
 
     @property
     def source(self):
@@ -384,9 +401,12 @@ class CharmRecipe(StormBase):
         """See `ICharmRecipe`."""
         if self.information_type in PUBLIC_INFORMATION_TYPES:
             return True
-        # XXX cjwatson 2021-05-27: Finish implementing this once we have
-        # more privacy infrastructure.
-        return False
+        if user is None:
+            return False
+        return not IStore(CharmRecipe).find(
+            CharmRecipe,
+            CharmRecipe.id == self.id,
+            get_charm_recipe_privacy_filter(user)).is_empty()
 
     def _isBuildableArchitectureAllowed(self, das):
         """Check whether we may build for a buildable `DistroArchSeries`.
@@ -675,6 +695,82 @@ class CharmRecipeSet:
         return IStore(CharmRecipe).find(
             CharmRecipe, owner=owner, project=project, name=name).one()
 
+    def _getRecipesFromCollection(self, collection, owner=None,
+                                  visible_by_user=None):
+        id_column = CharmRecipe.git_repository_id
+        ids = collection.getRepositoryIds()
+        expressions = [id_column.is_in(ids._get_select())]
+        if owner is not None:
+            expressions.append(CharmRecipe.owner == owner)
+        expressions.append(get_charm_recipe_privacy_filter(visible_by_user))
+        return IStore(CharmRecipe).find(CharmRecipe, *expressions)
+
+    def findByPerson(self, person, visible_by_user=None):
+        """See `ICharmRecipeSet`."""
+        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 `ICharmRecipeSet`."""
+        def _getRecipes(collection):
+            return self._getRecipesFromCollection(
+                collection.visibleByUser(visible_by_user),
+                visible_by_user=visible_by_user)
+
+        recipes_for_project = IStore(CharmRecipe).find(
+            CharmRecipe,
+            CharmRecipe.project == project,
+            get_charm_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 `ICharmRecipeSet`."""
+        clauses = [CharmRecipe.git_repository == repository]
+        if paths is not None:
+            clauses.append(CharmRecipe.git_path.is_in(paths))
+        if check_permissions:
+            clauses.append(get_charm_recipe_privacy_filter(visible_by_user))
+        return IStore(CharmRecipe).find(CharmRecipe, *clauses)
+
+    def findByGitRef(self, ref, visible_by_user=None):
+        """See `ICharmRecipeSet`."""
+        return IStore(CharmRecipe).find(
+            CharmRecipe,
+            CharmRecipe.git_repository == ref.repository,
+            CharmRecipe.git_path == ref.path,
+            get_charm_recipe_privacy_filter(visible_by_user))
+
+    def findByContext(self, context, visible_by_user=None, order_by_date=True):
+        """See `ICharmRecipeSet`."""
+        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 BadCharmRecipeSearchContext(context)
+        if order_by_date:
+            recipes = recipes.order_by(Desc(CharmRecipe.date_last_modified))
+        return recipes
+
     def isValidInformationType(self, information_type, owner, git_ref=None):
         """See `ICharmRecipeSet`."""
         private = information_type not in PUBLIC_INFORMATION_TYPES
@@ -698,6 +794,8 @@ class CharmRecipeSet:
         """See `ICharmRecipeSet`."""
         recipes = [removeSecurityProxy(recipe) for recipe in recipes]
 
+        load_related(Product, recipes, ["project_id"])
+
         person_ids = set()
         for recipe in recipes:
             person_ids.add(recipe.registrant_id)
@@ -708,6 +806,13 @@ class CharmRecipeSet:
         if repositories:
             GenericGitCollection.preloadDataForRepositories(repositories)
 
+        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
+
         # 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.
@@ -760,16 +865,20 @@ class CharmRecipeSet:
 
         return charmcraft_data
 
-    def findByGitRepository(self, repository, paths=None):
-        """See `ICharmRecipeSet`."""
-        clauses = [CharmRecipe.git_repository == repository]
-        if paths is not None:
-            clauses.append(CharmRecipe.git_path.is_in(paths))
-        # XXX cjwatson 2021-05-26: Check permissions once we have some
-        # privacy infrastructure.
-        return IStore(CharmRecipe).find(CharmRecipe, *clauses)
-
     def detachFromGitRepository(self, repository):
         """See `ICharmRecipeSet`."""
-        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_charm_recipe_privacy_filter(user):
+    """Return a Storm query filter to find charm recipes visible to `user`."""
+    public_filter = CharmRecipe.information_type.is_in(
+        PUBLIC_INFORMATION_TYPES)
+
+    # XXX cjwatson 2021-06-07: Flesh this out once we have more privacy
+    # infrastructure.
+    return [public_filter]
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index 24d7ff7..088b7b1 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -47,6 +47,8 @@ from lp.charms.interfaces.charmrecipebuild import (
 from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.person import Person
 from lp.services.config import config
 from lp.services.database.bulk import load_related
@@ -67,6 +69,7 @@ from lp.services.propertycache import (
     get_property_cache,
     )
 from lp.services.webapp.snapshot import notify_modified
+from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
 @implementer(ICharmRecipeBuild)
@@ -431,6 +434,11 @@ class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
         load_related(Person, builds, ["requester_id"])
         lfas = load_related(LibraryFileAlias, builds, ["log_id"])
         load_related(LibraryFileContent, lfas, ["contentID"])
+        distroarchserieses = load_related(
+            DistroArchSeries, builds, ["distro_arch_series_id"])
+        distroserieses = load_related(
+            DistroSeries, distroarchserieses, ["distroseriesID"])
+        load_related(Distribution, distroserieses, ["distributionID"])
         recipes = load_related(CharmRecipe, builds, ["recipe_id"])
         getUtility(ICharmRecipeSet).preloadDataForRecipes(recipes)
 
diff --git a/lib/lp/charms/templates/charmrecipe-listing.pt b/lib/lp/charms/templates/charmrecipe-listing.pt
new file mode 100644
index 0000000..8f163d7
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-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="charm-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/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt
new file mode 100644
index 0000000..93b4d69
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-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-charm-recipes"
+  tal:define="context_menu context/menu:context"
+  id="related-charm-recipes">
+
+  <h3>Related charm recipes</h3>
+
+  <div id="charm-recipe-links" class="actions">
+    <div id="charm-recipe-summary">
+      <tal:charm_recipes replace="structure view/charm_recipes_link" />
+    </div>
+  </div>
+
+</div>
+
+</tal:root>
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 173e024..af7f1e3 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -34,6 +34,7 @@ from lp.buildmaster.interfaces.processor import (
     )
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.charms.interfaces.charmrecipe import (
+    BadCharmRecipeSearchContext,
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_BUILD_DISTRIBUTION,
     CharmRecipeBuildAlreadyPending,
@@ -583,6 +584,40 @@ class TestCharmRecipeSet(TestCaseWithFactory):
             getUtility(ICharmRecipeSet).getByName(
                 owner, project, "proj-charm"))
 
+    def test_findByPerson(self):
+        # ICharmRecipeSet.findByPerson returns all charm 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.makeCharmRecipe(
+                registrant=owner, owner=owner))
+            [ref] = self.factory.makeGitRefs(owner=owner)
+            recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
+        recipe_set = getUtility(ICharmRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByPerson(owners[0]))
+        self.assertContentEqual(
+            recipes[2:], recipe_set.findByPerson(owners[1]))
+
+    def test_findByProject(self):
+        # ICharmRecipeSet.findByProject returns all charm recipes based on
+        # repositories for the given project, and charm 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.makeCharmRecipe(git_ref=ref))
+            recipes.append(self.factory.makeCharmRecipe(project=project))
+        [ref] = self.factory.makeGitRefs(target=None)
+        recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
+        recipe_set = getUtility(ICharmRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByProject(projects[0]))
+        self.assertContentEqual(
+            recipes[2:4], recipe_set.findByProject(projects[1]))
+
     def test_findByGitRepository(self):
         # ICharmRecipeSet.findByGitRepository returns all charm recipes with
         # the given Git repository.
@@ -620,6 +655,55 @@ class TestCharmRecipeSet(TestCaseWithFactory):
                 repositories[0],
                 paths=[recipes[0].git_ref.path, recipes[1].git_ref.path]))
 
+    def test_findByGitRef(self):
+        # ICharmRecipeSet.findByGitRef returns all charm recipes with the
+        # given Git reference.
+        repositories = [self.factory.makeGitRepository() for i in range(2)]
+        refs = []
+        recipes = []
+        for repository in repositories:
+            refs.extend(self.factory.makeGitRefs(
+                paths=["refs/heads/master", "refs/heads/other"]))
+            recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-2]))
+            recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-1]))
+        recipe_set = getUtility(ICharmRecipeSet)
+        for ref, recipe in zip(refs, recipes):
+            self.assertContentEqual([recipe], recipe_set.findByGitRef(ref))
+
+    def test_findByContext(self):
+        # ICharmRecipeSet.findByContext returns all charm 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.makeCharmRecipe(git_ref=refs[0]))
+        recipes.append(self.factory.makeCharmRecipe(git_ref=refs[1]))
+        recipes.append(self.factory.makeCharmRecipe(
+            registrant=person, owner=person, git_ref=other_refs[0]))
+        recipes.append(self.factory.makeCharmRecipe(
+            project=project, git_ref=other_refs[1]))
+        recipe_set = getUtility(ICharmRecipeSet)
+        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(
+            BadCharmRecipeSearchContext, recipe_set.findByContext,
+            self.factory.makeDistribution())
+
     def test_detachFromGitRepository(self):
         # ICharmRecipeSet.detachFromGitRepository clears the given Git
         # repository from all charm recipes.
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index b33dd20..c53f8f1 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -35,6 +35,10 @@ from lp.app.browser.launchpadform import (
     action,
     LaunchpadFormView,
     )
+from lp.charms.browser.hascharmrecipes import (
+    HasCharmRecipesMenuMixin,
+    HasCharmRecipesViewMixin,
+    )
 from lp.code.browser.branchmergeproposal import (
     latest_proposals_for_each_branch,
     )
@@ -71,7 +75,9 @@ from lp.snappy.browser.hassnaps import (
     )
 
 
-class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
+class GitRefContextMenu(
+        ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin,
+        HasCharmRecipesMenuMixin):
     """Context menu for Git references."""
 
     usedfor = IGitRef
@@ -82,6 +88,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
         'create_snap',
         'register_merge',
         'source',
+        'view_charm_recipes',
         'view_recipes',
         ]
 
@@ -111,7 +118,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
         return Link("+new-recipe", text, enabled=enabled, icon="add")
 
 
-class GitRefView(LaunchpadView, HasSnapsViewMixin):
+class GitRefView(LaunchpadView, HasSnapsViewMixin, HasCharmRecipesViewMixin):
 
     # 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/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index c41cafe..fb51ca1 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -75,6 +75,7 @@ from lp.app.errors import (
     )
 from lp.app.vocabularies import InformationTypeVocabulary
 from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesViewMixin
 from lp.code.browser.branch import CodeEditOwnerMixin
 from lp.code.browser.branchmergeproposal import (
     latest_proposals_for_each_branch,
@@ -368,7 +369,8 @@ class GitRefBatchNavigator(TableBatchNavigator):
 
 
 class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
-                        HasSnapsViewMixin, CodeImportTargetMixin):
+                        HasSnapsViewMixin, HasCharmRecipesViewMixin,
+                        CodeImportTargetMixin):
 
     @property
     def page_title(self):
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 0a98bd3..d5a5b38 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -793,6 +793,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
         Store.of(self).find(
             GitRef,
             GitRef.repository == self, GitRef.path.is_in(paths)).remove()
+        # 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.
+        for recipe in getUtility(ICharmRecipeSet).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 548ef39..1916bb0 100644
--- a/lib/lp/code/templates/gitref-index.pt
+++ b/lib/lp/code/templates/gitref-index.pt
@@ -38,6 +38,7 @@
            replace="structure context/@@++ref-pending-merges" />
       <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>
   </div>
 
diff --git a/lib/lp/code/templates/gitrepository-index.pt b/lib/lp/code/templates/gitrepository-index.pt
index 89f202f..c7da7d1 100644
--- a/lib/lp/code/templates/gitrepository-index.pt
+++ b/lib/lp/code/templates/gitrepository-index.pt
@@ -65,6 +65,7 @@
       <div metal:use-macro="context/@@+snap-macros/related-snaps">
         <metal:context-type fill-slot="context_type">repository</metal:context-type>
       </div>
+      <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
     </div>
   </div>
 
diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
index d0766c5..233113d 100644
--- a/lib/lp/registry/browser/person.py
+++ b/lib/lp/registry/browser/person.py
@@ -135,6 +135,7 @@ from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
 from lp.bugs.interfaces.bugtask import BugTaskStatus
 from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
 from lp.buildmaster.enums import BuildStatus
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.code.errors import InvalidNamespace
 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
@@ -812,7 +813,8 @@ class PersonMenuMixin(CommonMenuLinks):
 
 
 class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
-                         HasSnapsMenuMixin, HasOCIRecipesMenuMixin):
+                         HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
+                         HasCharmRecipesMenuMixin):
 
     usedfor = IPerson
     facet = 'overview'
@@ -842,6 +844,7 @@ class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
         'oauth_tokens',
         'oci_registry_credentials',
         'related_software_summary',
+        'view_charm_recipes',
         'view_recipes',
         'view_snaps',
         'view_oci_recipes',
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index df7ee7a..628fe0e 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -139,6 +139,7 @@ from lp.bugs.browser.structuralsubscription import (
     StructuralSubscriptionTargetTraversalMixin,
     )
 from lp.bugs.interfaces.bugtask import RESOLVED_BUGTASK_STATUSES
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
 from lp.code.browser.branchref import BranchRef
 from lp.code.browser.codeimport import (
     CodeImportNameValidationMixin,
@@ -233,7 +234,6 @@ from lp.services.webapp.vhosts import allvhosts
 from lp.services.worlddata.helpers import browser_languages
 from lp.services.worlddata.interfaces.country import ICountry
 from lp.snappy.browser.hassnaps import HasSnapsMenuMixin
-from lp.snappy.interfaces.snap import ISnapSet
 from lp.translations.browser.customlanguagecode import (
     HasCustomLanguageCodesTraversalMixin,
     )
@@ -559,7 +559,8 @@ class ProductActionNavigationMenu(NavigationMenu, ProductEditLinksMixin):
 
 
 class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
-                          HasRecipesMenuMixin, HasSnapsMenuMixin):
+                          HasRecipesMenuMixin, HasSnapsMenuMixin,
+                          HasCharmRecipesMenuMixin):
 
     usedfor = IProduct
     facet = 'overview'
@@ -584,6 +585,7 @@ class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
         'review_license',
         'rdf',
         'branding',
+        'view_charm_recipes',
         'view_recipes',
         'view_snaps',
         'create_snap',
diff --git a/lib/lp/registry/browser/team.py b/lib/lp/registry/browser/team.py
index d419603..980ee1e 100644
--- a/lib/lp/registry/browser/team.py
+++ b/lib/lp/registry/browser/team.py
@@ -93,6 +93,7 @@ from lp.app.widgets.itemswidgets import (
     )
 from lp.app.widgets.owner import HiddenUserWidget
 from lp.app.widgets.popup import PersonPickerWidget
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.oci.browser.hasocirecipes import HasOCIRecipesMenuMixin
 from lp.registry.browser.branding import BrandingChangeView
@@ -1624,7 +1625,8 @@ class TeamMenuMixin(PPANavigationMenuMixIn, CommonMenuLinks):
 
 
 class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
-                       HasSnapsMenuMixin, HasOCIRecipesMenuMixin):
+                       HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
+                       HasCharmRecipesMenuMixin):
 
     usedfor = ITeam
     facet = 'overview'
@@ -1652,6 +1654,7 @@ class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
         'maintained',
         'ppa',
         'related_software_summary',
+        'view_charm_recipes',
         'view_recipes',
         'view_snaps',
         'view_oci_recipes',
diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
index 7d60223..242483d 100644
--- a/lib/lp/registry/templates/product-index.pt
+++ b/lib/lp/registry/templates/product-index.pt
@@ -188,6 +188,10 @@
                   tal:condition="link/enabled">
                 <a tal:replace="structure link/fmt:link" />
               </li>
+              <li tal:define="link context/menu:overview/view_charm_recipes"
+                  tal:condition="link/enabled">
+                <a tal:replace="structure link/fmt:link" />
+              </li>
             </ul>
           </div>
         </div>
diff --git a/lib/lp/soyuz/templates/person-portlet-ppas.pt b/lib/lp/soyuz/templates/person-portlet-ppas.pt
index 19b7e6e..a97a9d5 100644
--- a/lib/lp/soyuz/templates/person-portlet-ppas.pt
+++ b/lib/lp/soyuz/templates/person-portlet-ppas.pt
@@ -34,10 +34,12 @@
   <ul class="horizontal" style="margin-top: 0;"
       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"
+                  oci_recipes_link context/menu:overview/view_oci_recipes;
+                  charm_recipes_link context/menu:overview/view_charm_recipes"
       tal:condition="python: recipes_link.enabled
                              or snaps_link.enabled
-                             or oci_recipes_link.enabled">
+                             or oci_recipes_link.enabled
+                             or charm_recipes_link.enabled">
     <li tal:condition="recipes_link/enabled">
       <a tal:replace="structure recipes_link/fmt:link" />
     </li>
@@ -47,5 +49,8 @@
     <li tal:condition="oci_recipes_link/enabled">
       <a tal:replace="structure oci_recipes_link/fmt:link" />
     </li>
+    <li tal:condition="charm_recipes_link/enabled">
+      <a tal:replace="structure charm_recipes_link/fmt:link" />
+    </li>
   </ul>
 </tal:root>