← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ruinedyourlife/launchpad:completing-craft-api into launchpad:master

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:completing-craft-api into launchpad:master with ~ruinedyourlife/launchpad:delete-craft-recipe-builds-and-jobs-when-deleting-recipes as a prerequisite.

Commit message:
Complete craft api without frontend integration

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Implementing the changes similar to the rock ones but without all the frontend elements to speed up the process
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:completing-craft-api into launchpad:master.
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 724a9eb..440b270 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -103,6 +103,7 @@ from lp.code.model.gitrule import GitRule, GitRuleGrant
 from lp.code.model.gitsubscription import GitSubscription
 from lp.code.model.reciperegistry import recipe_registry
 from lp.code.model.revisionstatus import RevisionStatusReport
+from lp.crafts.interfaces.craftrecipe import ICraftRecipeSet
 from lp.registry.enums import PersonVisibility
 from lp.registry.errors import CannotChangeInformationType
 from lp.registry.interfaces.accesspolicy import (
@@ -932,6 +933,10 @@ class GitRepository(
             self, paths=paths
         ):
             get_property_cache(recipe)._git_ref = None
+        for recipe in getUtility(ICraftRecipeSet).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/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index 0b9b69b..1fa3858 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -31,7 +31,7 @@ import http.client
 from lazr.enum import EnumeratedType, Item
 from lazr.restful.declarations import error_status, exported
 from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
-from zope.interface import Interface
+from zope.interface import Attribute, Interface
 from zope.schema import (
     Bool,
     Choice,
@@ -285,6 +285,8 @@ class ICraftRecipeView(Interface):
         description=_("The person who registered this craft recipe."),
     )
 
+    source = Attribute("The source branch for this craft recipe.")
+
     private = Bool(
         title=_("Private"),
         required=False,
@@ -363,6 +365,53 @@ class ICraftRecipeView(Interface):
         :return: `ICraftRecipeBuildRequest`.
         """
 
+    pending_build_requests = CollectionField(
+        title=_("Pending build requests for this craft recipe."),
+        value_type=Reference(ICraftRecipeBuildRequest),
+        required=True,
+        readonly=True,
+    )
+
+    failed_build_requests = CollectionField(
+        title=_("Failed build requests for this craft recipe."),
+        value_type=Reference(ICraftRecipeBuildRequest),
+        required=True,
+        readonly=True,
+    )
+
+    builds = CollectionField(
+        title=_("All builds of this craft recipe."),
+        description=_(
+            "All builds of this craft recipe, sorted in descending order "
+            "of finishing (or starting if not completed successfully)."
+        ),
+        # Really ICraftRecipeBuild.
+        value_type=Reference(schema=Interface),
+        readonly=True,
+    )
+
+    completed_builds = CollectionField(
+        title=_("Completed builds of this craft recipe."),
+        description=_(
+            "Completed builds of this craft recipe, sorted in descending "
+            "order of finishing."
+        ),
+        # Really ICraftRecipeBuild.
+        value_type=Reference(schema=Interface),
+        readonly=True,
+    )
+
+    pending_builds = CollectionField(
+        title=_("Pending builds of this craft recipe."),
+        description=_(
+            "Pending builds of this craft recipe, sorted in descending "
+            "order of creation."
+        ),
+        # Really ICraftRecipeBuild.
+        value_type=Reference(schema=Interface),
+        readonly=True,
+    )
+
 
 class ICraftRecipeEdit(Interface):
     """`ICraftRecipe` methods that require launchpad.Edit permission."""
@@ -424,7 +473,7 @@ class ICraftRecipeEditableAttributes(Interface):
     git_path = TextLine(
         title=_("Git branch path"),
         required=False,
-        readonly=False,
+        readonly=True,
         description=_(
             "The path of the Git branch containing a craft.yaml recipe."
         ),
@@ -583,12 +632,13 @@ class ICraftRecipeSet(Interface):
     def isValidInformationType(information_type, owner, git_ref=None):
         """Whether the information type context is valid."""
 
-    def findByGitRepository(repository, paths=None):
+    def findByGitRepository(repository, paths=None, check_permissions=True):
         """Return all craft recipes for the given Git repository.
 
         :param repository: An `IGitRepository`.
         :param paths: If not None, only return craft recipes for one of
             these Git reference paths.
+        :param check_permissions: If True, check the user's permissions.
         """
 
     def findByOwner(owner):
@@ -620,3 +670,38 @@ class ICraftRecipeSet(Interface):
         :raises CannotParseSourcecraftYaml: if the fetched sourcecraft.yaml
             cannot be parsed.
         """
+
+    def findByPerson(person, visible_by_user=None):
+        """Return all craft recipes relevant to `person`.
+
+        This returns craft recipes for Git branches owned by `person`, or
+        where `person` is the owner of the craft 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 craft 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 findByGitRef(ref):
+        """Return all craft recipes for the given Git reference."""
+
+    def findByContext(context, visible_by_user=None, order_by_date=True):
+        """Return all craft 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 BadCraftRecipeSearchContext: if the context is not
+            understood.
+        """
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index a0af625..cf4670c 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -5,6 +5,7 @@
 
 __all__ = [
     "CraftRecipe",
+    "get_craft_recipe_privacy_filter",
 ]
 
 from datetime import timezone
@@ -17,8 +18,10 @@ from storm.locals import (
     And,
     Bool,
     DateTime,
+    Desc,
     Int,
     Join,
+    Not,
     Or,
     Reference,
     Select,
@@ -36,16 +39,26 @@ from lp.app.enums import (
     InformationType,
 )
 from lp.buildmaster.enums import BuildStatus
+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
+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.code.model.reciperegistry import recipe_registry
 from lp.crafts.adapters.buildarch import determine_instances_to_build
 from lp.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
     CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+    BadCraftRecipeSearchContext,
     CannotFetchSourcecraftYaml,
     CannotParseSourcecraftYaml,
     CraftRecipeBuildAlreadyPending,
@@ -70,9 +83,15 @@ from lp.crafts.interfaces.craftrecipejob import (
 from lp.crafts.model.craftrecipebuild import CraftRecipeBuild
 from lp.crafts.model.craftrecipejob import CraftRecipeJob
 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
 from lp.services.database.bulk import load_related
 from lp.services.database.constants import DEFAULT, UTC_NOW
@@ -80,6 +99,7 @@ from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.interfaces import IPrimaryStore, IStore
 from lp.services.database.stormbase import StormBase
+from lp.services.database.stormexpr import Greatest, NullsLast
 from lp.services.features import getFeatureFlag
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import Job
@@ -241,14 +261,18 @@ class CraftRecipe(StormBase):
         """See `ICraftRecipe`."""
         return self.information_type not in PUBLIC_INFORMATION_TYPES
 
-    @property
-    def git_ref(self):
-        """See `ICraftRecipe`."""
+    @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 `ICraftRecipe`."""
+        return self._git_ref
+
     @git_ref.setter
     def git_ref(self, value):
         """See `ICraftRecipe`."""
@@ -258,6 +282,12 @@ class CraftRecipe(StormBase):
         else:
             self.git_repository = None
             self.git_path = None
+        get_property_cache(self)._git_ref = value
+
+    @property
+    def source(self):
+        """See `ICraftRecipe`."""
+        return self.git_ref
 
     @property
     def store_channels(self):
@@ -279,9 +309,17 @@ class CraftRecipe(StormBase):
         """See `ICraftRecipe`."""
         if self.information_type in PUBLIC_INFORMATION_TYPES:
             return True
-        # XXX ruinedyourlife 2024-09-24: Finish implementing this once we have
-        # more privacy infrastructure.
-        return False
+        if user is None:
+            return False
+        return (
+            not IStore(CraftRecipe)
+            .find(
+                CraftRecipe,
+                CraftRecipe.id == self.id,
+                get_craft_recipe_privacy_filter(user),
+            )
+            .is_empty()
+        )
 
     def _isBuildableArchitectureAllowed(self, das):
         """Check whether we may build for a buildable `DistroArchSeries`.
@@ -515,6 +553,98 @@ class CraftRecipe(StormBase):
         """See `ICraftRecipe`."""
         return CraftRecipeBuildRequest(self, job_id)
 
+    @property
+    def pending_build_requests(self):
+        """See `ICraftRecipe`."""
+        job_source = getUtility(ICraftRecipeRequestBuildsJobSource)
+        # The returned jobs are ordered by descending ID.
+        jobs = job_source.findByRecipe(
+            self, statuses=(JobStatus.WAITING, JobStatus.RUNNING)
+        )
+        return DecoratedResultSet(
+            jobs, result_decorator=CraftRecipeBuildRequest.fromJob
+        )
+
+    @property
+    def failed_build_requests(self):
+        """See `ICraftRecipe`."""
+        job_source = getUtility(ICraftRecipeRequestBuildsJobSource)
+        # The returned jobs are ordered by descending ID.
+        jobs = job_source.findByRecipe(self, statuses=(JobStatus.FAILED,))
+        return DecoratedResultSet(
+            jobs, result_decorator=CraftRecipeBuildRequest.fromJob
+        )
+
+    def _getBuilds(self, filter_term, order_by):
+        """The actual query to get the builds."""
+        query_args = [
+            CraftRecipeBuild.recipe == self,
+        ]
+        if filter_term is not None:
+            query_args.append(filter_term)
+        result = Store.of(self).find(CraftRecipeBuild, *query_args)
+        result.order_by(order_by)
+
+        def eager_load(rows):
+            getUtility(ICraftRecipeBuildSet).preloadBuildsData(rows)
+            getUtility(IBuildQueueSet).preloadForBuildFarmJobs(rows)
+            load_related(Builder, rows, ["builder_id"])
+
+        return DecoratedResultSet(result, pre_iter_hook=eager_load)
+
+    @property
+    def builds(self):
+        """See `ICraftRecipe`."""
+        order_by = (
+            NullsLast(
+                Desc(
+                    Greatest(
+                        CraftRecipeBuild.date_started,
+                        CraftRecipeBuild.date_finished,
+                    )
+                )
+            ),
+            Desc(CraftRecipeBuild.date_created),
+            Desc(CraftRecipeBuild.id),
+        )
+        return self._getBuilds(None, order_by)
+
+    @property
+    def _pending_states(self):
+        """All the build states we consider pending (non-final)."""
+        return [
+            BuildStatus.NEEDSBUILD,
+            BuildStatus.BUILDING,
+            BuildStatus.UPLOADING,
+            BuildStatus.CANCELLING,
+        ]
+
+    @property
+    def completed_builds(self):
+        """See `ICraftRecipe`."""
+        filter_term = Not(CraftRecipeBuild.status.is_in(self._pending_states))
+        order_by = (
+            NullsLast(
+                Desc(
+                    Greatest(
+                        CraftRecipeBuild.date_started,
+                        CraftRecipeBuild.date_finished,
+                    )
+                )
+            ),
+            Desc(CraftRecipeBuild.id),
+        )
+        return self._getBuilds(filter_term, order_by)
+
+    @property
+    def pending_builds(self):
+        """See `ICraftRecipe`."""
+        filter_term = CraftRecipeBuild.status.is_in(self._pending_states)
+        # We want to order by date_created but this is the same as ordering
+        # by id (since id increases monotonically) and is less expensive.
+        order_by = Desc(CraftRecipeBuild.id)
+        return self._getBuilds(filter_term, order_by)
+
 
 @recipe_registry.register_recipe_type(
     ICraftRecipeSet, "Some craft recipes build from this repository."
@@ -627,28 +757,109 @@ class CraftRecipeSet:
 
         return True
 
-    def findByGitRepository(self, repository, paths=None):
+    def _getRecipesFromCollection(
+        self, collection, owner=None, visible_by_user=None
+    ):
+        id_column = CraftRecipe.git_repository_id
+        ids = collection.getRepositoryIds()
+        expressions = [id_column.is_in(ids._get_select())]
+        if owner is not None:
+            expressions.append(CraftRecipe.owner == owner)
+        expressions.append(get_craft_recipe_privacy_filter(visible_by_user))
+        return IStore(CraftRecipe).find(CraftRecipe, *expressions)
+
+    def findByPerson(self, person, visible_by_user=None):
+        """See `ICraftRecipeSet`."""
+
+        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 `ICraftRecipeSet`."""
+
+        def _getRecipes(collection):
+            return self._getRecipesFromCollection(
+                collection.visibleByUser(visible_by_user),
+                visible_by_user=visible_by_user,
+            )
+
+        recipes_for_project = IStore(CraftRecipe).find(
+            CraftRecipe,
+            CraftRecipe.project == project,
+            get_craft_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 `ICraftRecipeSet`."""
         clauses = [CraftRecipe.git_repository == repository]
         if paths is not None:
             clauses.append(CraftRecipe.git_path.is_in(paths))
-        # XXX ruinedyourlife 2024-09-24: Check permissions once we have some
-        # privacy infrastructure.
+        if check_permissions:
+            clauses.append(get_craft_recipe_privacy_filter(visible_by_user))
         return IStore(CraftRecipe).find(CraftRecipe, *clauses)
 
-    def findByOwner(self, owner):
+    def findByGitRef(self, ref, visible_by_user=None):
         """See `ICraftRecipeSet`."""
-        return IStore(CraftRecipe).find(CraftRecipe, owner=owner)
+        return IStore(CraftRecipe).find(
+            CraftRecipe,
+            CraftRecipe.git_repository == ref.repository,
+            CraftRecipe.git_path == ref.path,
+            get_craft_recipe_privacy_filter(visible_by_user),
+        )
 
-    def detachFromGitRepository(self, repository):
+    def findByContext(self, context, visible_by_user=None, order_by_date=True):
         """See `ICraftRecipeSet`."""
-        self.findByGitRepository(repository).set(
-            git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
-        )
+        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 BadCraftRecipeSearchContext(context)
+        if order_by_date:
+            recipes = recipes.order_by(Desc(CraftRecipe.date_last_modified))
+        return recipes
+
+    def findByOwner(self, owner):
+        """See `ICraftRecipeSet`."""
+        return IStore(CraftRecipe).find(CraftRecipe, owner=owner)
 
     def preloadDataForRecipes(self, recipes, user=None):
         """See `ICraftRecipeSet`."""
         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)
@@ -660,6 +871,14 @@ class CraftRecipeSet:
         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.
@@ -671,6 +890,15 @@ class CraftRecipeSet:
             )
         )
 
+    def detachFromGitRepository(self, repository):
+        """See `ICraftRecipeSet`."""
+        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 getSourcecraftYaml(self, context, logger=None):
         """See `ICraftRecipeSet`."""
         if ICraftRecipe.providedBy(context):
@@ -790,3 +1018,14 @@ class CraftRecipeBuildRequest:
     def architectures(self):
         """See `ICraftRecipeBuildRequest`."""
         return self._job.architectures
+
+
+def get_craft_recipe_privacy_filter(user):
+    """Return a Storm query filter to find craft recipes visible to `user`."""
+    public_filter = CraftRecipe.information_type.is_in(
+        PUBLIC_INFORMATION_TYPES
+    )
+
+    # XXX ruinedyourlife 2024-10-02: Flesh this out once we have more privacy
+    # infrastructure.
+    return [public_filter]
diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py
index 6f989e9..e859c6b 100644
--- a/lib/lp/crafts/model/craftrecipebuild.py
+++ b/lib/lp/crafts/model/craftrecipebuild.py
@@ -35,6 +35,8 @@ from lp.crafts.interfaces.craftrecipebuild import (
 from lp.crafts.mail.craftrecipebuild import CraftRecipeBuildMailer
 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
@@ -46,6 +48,7 @@ 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
+from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
 @implementer(ICraftRecipeBuild)
@@ -408,6 +411,13 @@ class CraftRecipeBuildSet(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, ["distroseries_id"]
+        )
+        load_related(Distribution, distroserieses, ["distribution_id"])
         recipes = load_related(CraftRecipe, builds, ["recipe_id"])
         getUtility(ICraftRecipeSet).preloadDataForRecipes(recipes)
 
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
index afd74b3..cbff62a 100644
--- a/lib/lp/crafts/tests/test_craftrecipe.py
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -30,6 +30,7 @@ from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.tests.helpers import GitHostingFixture
 from lp.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
+    BadCraftRecipeSearchContext,
     CraftRecipeBuildAlreadyPending,
     CraftRecipeBuildDisallowedArchitecture,
     CraftRecipeBuildRequestStatus,
@@ -692,6 +693,111 @@ class TestCraftRecipeSet(TestCaseWithFactory):
                 recipe, "date_last_modified", UTC_NOW
             )
 
+    def test_findByPerson(self):
+        # ICraftRecipeSet.findByPerson returns all craft 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.makeCraftRecipe(registrant=owner, owner=owner)
+            )
+            [ref] = self.factory.makeGitRefs(owner=owner)
+            recipes.append(self.factory.makeCraftRecipe(git_ref=ref))
+        recipe_set = getUtility(ICraftRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByPerson(owners[0])
+        )
+        self.assertContentEqual(
+            recipes[2:], recipe_set.findByPerson(owners[1])
+        )
+
+    def test_findByProject(self):
+        # ICraftRecipeSet.findByProject returns all craft recipes based on
+        # repositories for the given project, and craft 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.makeCraftRecipe(git_ref=ref))
+            recipes.append(self.factory.makeCraftRecipe(project=project))
+        [ref] = self.factory.makeGitRefs(target=None)
+        recipes.append(self.factory.makeCraftRecipe(git_ref=ref))
+        recipe_set = getUtility(ICraftRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByProject(projects[0])
+        )
+        self.assertContentEqual(
+            recipes[2:4], recipe_set.findByProject(projects[1])
+        )
+
+    def test_findByGitRef(self):
+        # ICraftRecipeSet.findByGitRef returns all craft 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.makeCraftRecipe(git_ref=refs[-2]))
+            recipes.append(self.factory.makeCraftRecipe(git_ref=refs[-1]))
+        recipe_set = getUtility(ICraftRecipeSet)
+        for ref, recipe in zip(refs, recipes):
+            self.assertContentEqual([recipe], recipe_set.findByGitRef(ref))
+
+    def test_findByContext(self):
+        # ICraftRecipeSet.findByContext returns all craft 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.makeCraftRecipe(git_ref=refs[0]))
+        recipes.append(self.factory.makeCraftRecipe(git_ref=refs[1]))
+        recipes.append(
+            self.factory.makeCraftRecipe(
+                registrant=person, owner=person, git_ref=other_refs[0]
+            )
+        )
+        recipes.append(
+            self.factory.makeCraftRecipe(
+                project=project, git_ref=other_refs[1]
+            )
+        )
+        recipe_set = getUtility(ICraftRecipeSet)
+        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(
+            BadCraftRecipeSearchContext,
+            recipe_set.findByContext,
+            self.factory.makeDistribution(),
+        )
+
 
 class TestCraftRecipeDeleteWithBuilds(TestCaseWithFactory):