← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:oci-gitrepository into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:oci-gitrepository into launchpad:master.

Commit message:
Add GitNamespace for OCIProjects

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

The addition of the ociprojectname column to GitRepository requires a working Namespace implementation.

Add the model, following the pattern from DistributionSourcePackage.
Add tests for the model
Add required helper methods to IDistribution
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-gitrepository into launchpad:master.
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 0ae4a3c..4cda403 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -894,6 +894,10 @@
     <allow interface="lp.code.interfaces.gitnamespace.IGitNamespace" />
     <allow interface="lp.code.interfaces.gitnamespace.IGitNamespacePolicy" />
   </class>
+  <class class="lp.code.model.gitnamespace.OCIProjectGitNamespace">
+    <allow interface="lp.code.interfaces.gitnamespace.IGitNamespace" />
+    <allow interface="lp.code.interfaces.gitnamespace.IGitNamespacePolicy" />
+  </class>
   <securedutility
       class="lp.code.model.gitnamespace.GitNamespaceSet"
       provides="lp.code.interfaces.gitnamespace.IGitNamespaceSet">
diff --git a/lib/lp/code/interfaces/gitcollection.py b/lib/lp/code/interfaces/gitcollection.py
index 4ab976b..9b79940 100644
--- a/lib/lp/code/interfaces/gitcollection.py
+++ b/lib/lp/code/interfaces/gitcollection.py
@@ -138,6 +138,9 @@ class IGitCollection(Interface):
     def inDistributionSourcePackage(distro_source_package):
         """Restrict to repositories in a package for a distribution."""
 
+    def inOCIProject(oci_project):
+        """Restrict to repositories in a OCI Project for a distribution."""
+
     def isPersonal():
         """Restrict the collection to personal repositories."""
 
diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py
index 99841d7..ce61dde 100644
--- a/lib/lp/code/interfaces/gitnamespace.py
+++ b/lib/lp/code/interfaces/gitnamespace.py
@@ -22,6 +22,7 @@ from lp.code.errors import InvalidNamespace
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
 
@@ -198,7 +199,8 @@ class IGitNamespacePolicy(Interface):
 class IGitNamespaceSet(Interface):
     """Interface for getting Git repository namespaces."""
 
-    def get(person, project=None, distribution=None, sourcepackagename=None):
+    def get(person, project=None, distribution=None, sourcepackagename=None,
+            ociprojectname=None):
         """Return the appropriate `IGitNamespace` for the given objects."""
 
 
@@ -209,6 +211,10 @@ def get_git_namespace(target, owner):
         return getUtility(IGitNamespaceSet).get(
             owner, distribution=target.distribution,
             sourcepackagename=target.sourcepackagename)
+    elif IOCIProject.providedBy(target):
+        return getUtility(IGitNamespaceSet).get(
+            owner, distribution=target.distribution,
+            ociprojectname=target.ociprojectname)
     elif target is None or IPerson.providedBy(target):
         return getUtility(IGitNamespaceSet).get(owner)
     else:
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 2fca251..ed3fefc 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -73,6 +73,7 @@ from lp.code.interfaces.hasrecipes import IHasRecipes
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
+from lp.registry.interfaces.ociprojectname import IOCIProjectName
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.persondistributionsourcepackage import (
     IPersonDistributionSourcePackageFactory,
@@ -204,6 +205,12 @@ class IGitRepositoryView(IHasRecipes):
     shortened_path = Attribute(
         "The shortest reasonable version of the path to this repository.")
 
+    ociprojectname = exported(
+        Reference(
+            title=_("OCI Project Name"), required=False, readonly=False,
+            schema=IOCIProjectName,
+            description=_("The OCI project that this repository belongs to.")))
+
     @operation_parameters(
         reviewer=Reference(
             title=_("A person for which the reviewer status is in question."),
diff --git a/lib/lp/code/model/gitcollection.py b/lib/lp/code/model/gitcollection.py
index 70f6d01..e5ec794 100644
--- a/lib/lp/code/model/gitcollection.py
+++ b/lib/lp/code/model/gitcollection.py
@@ -458,6 +458,14 @@ class GenericGitCollection:
             [GitRepository.distribution == distribution,
              GitRepository.sourcepackagename == sourcepackagename])
 
+    def inOCIProject(self, oci_project):
+        """See `IGitcollection`."""
+        distribution = oci_project.pillar
+        ociprojectname = oci_project.ociprojectname
+        return self._filterBy(
+            [GitRepository.distribution == distribution,
+             GitRepository.ociprojectname == ociprojectname])
+
     def isPersonal(self):
         """See `IGitCollection`."""
         return self._filterBy(
diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py
index 75ea427..ca35444 100644
--- a/lib/lp/code/model/gitnamespace.py
+++ b/lib/lp/code/model/gitnamespace.py
@@ -6,6 +6,7 @@
 __metaclass__ = type
 __all__ = [
     'GitNamespaceSet',
+    'OCIProjectGitNamespace',
     'PackageGitNamespace',
     'PersonalGitNamespace',
     'ProjectGitNamespace',
@@ -541,12 +542,85 @@ class PackageGitNamespace(_BaseGitNamespace):
             self_dsp.sourcepackagename == other_dsp.sourcepackagename)
 
 
+@implementer(IGitNamespace, IGitNamespacePolicy)
+class OCIProjectGitNamespace(_BaseGitNamespace):
+    """A namespace for OCI Project repositories.
+
+    This namesace is for all the repositories owned by a particular person
+    in a particular OCI Project in a particular distribution.
+    """
+
+    has_defaults = True
+    allow_push_to_set_default = False
+    supports_merge_proposals = True
+    supports_code_imports = True
+    allow_recipe_name_from_target = True
+
+    def __init__(self, person, oci_project):
+        self.owner = person
+        self.oci_project = oci_project
+
+    def _getRepositoriesClause(self):
+        return And(
+            GitRepository.owner == self.owner,
+            GitRepository.ociprojectname == self.oci_project.ociprojectname,
+            GitRepository.distribution == self.oci_project.distribution)
+
+    # Marker for references to Git URL layouts: ##GITNAMESPACE##
+    @property
+    def name(self):
+        """See `IGitNamespace`."""
+        ocip = self.oci_project
+        return '~%s/%s/+oci/%s' % (
+            self.owner.name, ocip.pillar.name, ocip.ociprojectname.name)
+
+    @property
+    def target(self):
+        """See `IGitNamespace`."""
+        return IHasGitRepositories(self.oci_project)
+
+    def _retargetRepository(self, repository):
+        ocip = self.oci_project
+        repository.project = None
+        repository.distribution = ocip.distribution
+        repository.ociprojectname = ocip.ociprojectname
+
+    def getAllowedInformationTypes(self, who=None):
+        """See `IGitNamespace`."""
+        return PUBLIC_INFORMATION_TYPES
+
+    def getDefaultInformationType(self, who=None):
+        """See `IGitNamespace`."""
+        return InformationType.PUBLIC
+
+    def areRepositoriesMergeable(self, this, other):
+        """See `IGitNamespacePolicy`."""
+        # Repositories are mergeable into a oci project repository if the
+        # package is the same.
+        # XXX cjwatson 2015-04-18: Allow merging from a project repository
+        # if any (active?) series links this package to that project.
+        if this.namespace != self:
+            raise AssertionError(
+                "Namespace of %s is not %s." % (this.unique_name, self.name))
+        other_namespace = other.namespace
+        if zope_isinstance(other_namespace, OCIProjectGitNamespace):
+            return self.target == other_namespace.target
+        else:
+            return False
+
+    @property
+    def collection(self):
+        """See `IGitNamespacePolicy`."""
+        return getUtility(IAllGitRepositories).inOCIProject(
+            self.oci_project)
+
+
 @implementer(IGitNamespaceSet)
 class GitNamespaceSet:
     """Only implementation of `IGitNamespaceSet`."""
 
     def get(self, person, project=None, distribution=None,
-            sourcepackagename=None):
+            sourcepackagename=None, ociprojectname=None):
         """See `IGitNamespaceSet`."""
         if project is not None:
             assert distribution is None and sourcepackagename is None, (
@@ -555,10 +629,15 @@ class GitNamespaceSet:
                 % (project, distribution, sourcepackagename))
             return ProjectGitNamespace(person, project)
         elif distribution is not None:
+            if sourcepackagename is not None:
+                return PackageGitNamespace(
+                    person, distribution.getSourcePackage(sourcepackagename))
+            elif ociprojectname is not None:
+                return OCIProjectGitNamespace(
+                    person, distribution.getOCIProject(ociprojectname.name))
             assert sourcepackagename is not None, (
-                "distribution implies sourcepackagename. Got %r, %r"
-                % (distribution, sourcepackagename))
-            return PackageGitNamespace(
-                person, distribution.getSourcePackage(sourcepackagename))
+                "distribution implies sourcepackagename or ociprojectname. "
+                "Got %r, %r, %r"
+                % (distribution, sourcepackagename, ociprojectname))
         else:
             return PersonalGitNamespace(person)
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 93a2d16..f431a44 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -153,6 +153,7 @@ from lp.registry.interfaces.accesspolicy import (
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import (
     IPerson,
     IPersonSet,
@@ -318,6 +319,9 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
 
     _default_branch = Unicode(name='default_branch', allow_none=True)
 
+    ociprojectname_id = Int(name='ociprojectname', allow_none=True)
+    ociprojectname = Reference(ociprojectname_id, 'OCIProjectName.id')
+
     def __init__(self, repository_type, registrant, owner, target, name,
                  information_type, date_created, reviewer=None,
                  description=None):
@@ -339,6 +343,10 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
         elif IDistributionSourcePackage.providedBy(target):
             self.distribution = target.distribution
             self.sourcepackagename = target.sourcepackagename
+        elif IOCIProject.providedBy(target):
+            # XXX twom 2019-10-28 This should have support for product
+            self.ociprojectname = target.ociprojectname
+            self.distribution = target.pillar
         self.owner_default = False
         self.target_default = False
 
@@ -367,10 +375,16 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
         if self.project is not None:
             fmt = "~%(owner)s/%(project)s"
             names["project"] = self.project.name
-        elif self.distribution is not None:
+        elif (self.distribution is not None
+              and self.sourcepackagename is not None):
             fmt = "~%(owner)s/%(distribution)s/+source/%(source)s"
             names["distribution"] = self.distribution.name
             names["source"] = self.sourcepackagename.name
+        elif (self.distribution is not None
+              and self.ociprojectname is not None):
+            fmt = "~%(owner)s/%(distribution)s/+oci/%(ociproject)s"
+            names["distribution"] = self.distribution.name
+            names["ociproject"] = self.ociprojectname.name
         else:
             fmt = "~%(owner)s"
         fmt += "/+git/%(repository)s"
@@ -384,8 +398,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
         """See `IGitRepository`."""
         if self.project is not None:
             return self.project
-        elif self.distribution is not None:
+        elif (self.distribution is not None
+              and self.sourcepackagename is not None):
             return self.distribution.getSourcePackage(self.sourcepackagename)
+        elif (self.distribution is not None
+              and self.ociprojectname is not None):
+            return self.distribution.getOCIProject(self.ociprojectname.name)
         else:
             return self.owner
 
diff --git a/lib/lp/code/model/tests/test_gitcollection.py b/lib/lp/code/model/tests/test_gitcollection.py
index bd80685..91f6e4b 100644
--- a/lib/lp/code/model/tests/test_gitcollection.py
+++ b/lib/lp/code/model/tests/test_gitcollection.py
@@ -381,6 +381,20 @@ class TestGitCollectionFilters(TestCaseWithFactory):
             sorted([repository, repository2]),
             sorted(collection.getRepositories()))
 
+    def test_in_oci_project(self):
+        # 'inOCIProject' returns a new collection that only
+        # has repositories for the oci project in the distribution.
+        ocip = self.factory.makeOCIProject()
+        ocip_other_distro = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=ocip)
+        repository2 = self.factory.makeGitRepository(target=ocip)
+        self.factory.makeGitRepository(target=ocip_other_distro)
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.inOCIProject(ocip)
+        self.assertEqual(
+            sorted([repository, repository2]),
+            sorted(collection.getRepositories()))
+
     def test_withIds(self):
         # 'withIds' returns a new collection that only has repositories with
         # the given ids.
diff --git a/lib/lp/code/model/tests/test_gitnamespace.py b/lib/lp/code/model/tests/test_gitnamespace.py
index 8e8696f..4ca44cb 100644
--- a/lib/lp/code/model/tests/test_gitnamespace.py
+++ b/lib/lp/code/model/tests/test_gitnamespace.py
@@ -31,6 +31,7 @@ from lp.code.interfaces.gitnamespace import (
     )
 from lp.code.interfaces.gitrepository import IGitRepositorySet
 from lp.code.model.gitnamespace import (
+    OCIProjectGitNamespace,
     PackageGitNamespace,
     PersonalGitNamespace,
     ProjectGitNamespace,
@@ -436,6 +437,102 @@ class TestProjectGitNamespace(TestCaseWithFactory, NamespaceMixin):
             repositories[0].namespace.collection.getRepositories())
 
 
+class TestOCIProjectGitNamespace(TestCaseWithFactory, NamespaceMixin):
+    """Tests for `OCIProjectGitNamespace`."""
+
+    layer = DatabaseFunctionalLayer
+
+    def getNamespace(self, person=None):
+        if person is None:
+            person = self.factory.makePerson()
+        return get_git_namespace(self.factory.makeOCIProject(), person)
+
+    def test_name(self):
+        person = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        namespace = OCIProjectGitNamespace(person, oci_project)
+        self.assertEqual(
+            "~%s/%s/+oci/%s" % (
+                person.name, oci_project.distribution.name,
+                oci_project.ociprojectname.name),
+            namespace.name)
+
+    def test_owner(self):
+        # The person passed to an oci project namespace is the owner.
+        person = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        namespace = PackageGitNamespace(person, oci_project)
+        self.assertEqual(person, removeSecurityProxy(namespace).owner)
+
+    def test_target(self):
+        # The target for an oci project namespace is the oci project.
+        person = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        namespace = PackageGitNamespace(person, oci_project)
+        self.assertEqual(oci_project, namespace.target)
+
+    def test_supports_merge_proposals(self):
+        # OCI Project namespaces support merge proposals.
+        self.assertTrue(self.getNamespace().supports_merge_proposals)
+
+    def test_areRepositoriesMergeable_same_repository(self):
+        # A package repository is mergeable into itself.
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        self.assertTrue(
+            repository.namespace.areRepositoriesMergeable(
+                repository, repository))
+
+    def test_areRepositoriesMergeable_same_namespace(self):
+        # Repositories of the same package are mergeable.
+        oci_project = self.factory.makeOCIProject()
+        this = self.factory.makeGitRepository(target=oci_project)
+        other = self.factory.makeGitRepository(target=oci_project)
+        self.assertTrue(this.namespace.areRepositoriesMergeable(this, other))
+
+    def test_areRepositoriesMergeable_different_namespace(self):
+        # Repositories of a different package are not mergeable.
+        this_oci_project = self.factory.makeOCIProject()
+        this = self.factory.makeGitRepository(target=this_oci_project)
+        other_oci_project = self.factory.makeOCIProject()
+        other = self.factory.makeGitRepository(target=other_oci_project)
+        self.assertFalse(this.namespace.areRepositoriesMergeable(this, other))
+
+    def test_areRepositoriesMergeable_personal(self):
+        # Personal repositories are not mergeable into oci project
+        # repositories.
+        owner = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        this = self.factory.makeGitRepository(owner=owner, target=oci_project)
+        other = self.factory.makeGitRepository(owner=owner, target=owner)
+        self.assertFalse(this.namespace.areRepositoriesMergeable(this, other))
+
+    def test_areRepositoriesMergeable_project(self):
+        # Project repositories are not mergeable into oci project repositories.
+        owner = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        this = self.factory.makeGitRepository(owner=owner, target=oci_project)
+        project = self.factory.makeProduct()
+        other = self.factory.makeGitRepository(owner=owner, target=project)
+        self.assertFalse(this.namespace.areRepositoriesMergeable(this, other))
+
+    def test_collection(self):
+        # An oci projects namespace's collection is of
+        # repositories for the same oci project.
+        oci_project = self.factory.makeOCIProject()
+        repositories = [
+            self.factory.makeGitRepository(target=oci_project)
+            for _ in range(3)]
+        self.factory.makeGitRepository(
+            target=self.factory.makeOCIProject())
+        self.factory.makeGitRepository(target=self.factory.makeProduct())
+        self.factory.makeGitRepository(
+            owner=repositories[0].owner, target=repositories[0].owner)
+        self.assertContentEqual(
+            repositories,
+            repositories[0].namespace.collection.getRepositories())
+
+
 class TestProjectGitNamespacePrivacyWithInformationType(TestCaseWithFactory):
     """Tests for the privacy aspects of `ProjectGitNamespace`.
 
@@ -688,6 +785,15 @@ class TestPackageGitNamespace(TestCaseWithFactory, NamespaceMixin):
         other = self.factory.makeGitRepository(owner=owner, target=project)
         self.assertFalse(this.namespace.areRepositoriesMergeable(this, other))
 
+    def test_areRepositoriesMergeable_oci_project(self):
+        # OCI Project repositories are not mergeable into package repositories.
+        owner = self.factory.makePerson()
+        dsp = self.factory.makeDistributionSourcePackage()
+        this = self.factory.makeGitRepository(owner=owner, target=dsp)
+        oci_project = self.factory.makeOCIProject()
+        other = self.factory.makeGitRepository(owner=owner, target=oci_project)
+        self.assertFalse(this.namespace.areRepositoriesMergeable(this, other))
+
     def test_collection(self):
         # A package namespace's collection is of repositories for the same
         # package.
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 145bff4..299a088 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -218,6 +218,13 @@ class TestGitRepository(TestCaseWithFactory):
                 dsp.sourcepackagename.name, repository.name),
             repository.unique_name)
 
+    def test_unique_name_oci_project(self):
+        ociprojectname = self.factory.makeOCIProjectName()
+        oci_project = self.factory.makeOCIProject(
+            ociprojectname=ociprojectname)
+        repository = self.factory.makeGitRepository(target=oci_project)
+        self.assertEqual(ociprojectname, repository.ociprojectname)
+
     def test_unique_name_personal(self):
         owner = self.factory.makePerson()
         repository = self.factory.makeGitRepository(owner=owner, target=owner)
@@ -235,6 +242,11 @@ class TestGitRepository(TestCaseWithFactory):
         repository = self.factory.makeGitRepository(target=dsp)
         self.assertEqual(dsp, repository.target)
 
+    def test_target_ociproject(self):
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        self.assertEqual(oci_project, repository.target)
+
     def test_target_personal(self):
         owner = self.factory.makePerson()
         repository = self.factory.makeGitRepository(owner=owner, target=owner)
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index 88a0545..c7ddb95 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -500,6 +500,11 @@ class IDistributionPublic(
         order to create a mirror.
         """
 
+    def getOCIProject(name):
+        """Return a `OCIProject` with the given name for this
+        distribution, or None.
+        """
+
     @operation_parameters(
         name=TextLine(title=_("Package name"), required=True))
     # Really returns IDistributionSourcePackage, see
diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
index 100dccd..b48cdbb 100644
--- a/lib/lp/registry/interfaces/ociproject.py
+++ b/lib/lp/registry/interfaces/ociproject.py
@@ -24,6 +24,7 @@ from zope.schema import (
 
 from lp import _
 from lp.bugs.interfaces.bugtarget import IBugTarget
+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.ociprojectname import IOCIProjectName
 from lp.registry.interfaces.series import SeriesStatus
@@ -79,7 +80,7 @@ class IOCIProjectEdit(Interface):
         """Creates a new `IOCIProjectSeries`."""
 
 
-class IOCIProject(IOCIProjectView, IOCIProjectEdit,
+class IOCIProject(IHasGitRepositories, IOCIProjectView, IOCIProjectEdit,
                        IOCIProjectEditableAttributes):
     """A project containing Open Container Initiative recipes."""
 
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index 3bdedbe..2288d0e 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -101,6 +101,7 @@ from lp.registry.interfaces.distributionmirror import (
     MirrorFreshness,
     MirrorStatus,
     )
+from lp.registry.interfaces.ociproject import IOCIProjectSet
 from lp.registry.interfaces.oopsreferences import IHasOOPSReferences
 from lp.registry.interfaces.person import (
     validate_person,
@@ -826,6 +827,11 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             name = %s
             """ % sqlvalues(self.id, name))
 
+    def getOCIProject(self, name):
+        oci_project = getUtility(IOCIProjectSet).getByDistributionAndName(
+            self, name)
+        return oci_project
+
     def getSourcePackage(self, name):
         """See `IDistribution`."""
         if ISourcePackageName.providedBy(name):
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index 8e0b5ba..b9f49e6 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -304,6 +304,17 @@ class TestDistribution(TestCaseWithFactory):
             InformationType.PUBLIC,
             distro.getDefaultSpecificationInformationType())
 
+    def test_getOCIProject(self):
+        distro = self.factory.makeDistribution()
+        ociprojectname = self.factory.makeOCIProjectName(name=u'first-project')
+        first_project = self.factory.makeOCIProject(
+            ociprojectname=ociprojectname,
+            pillar=distro)
+        # make another project to ensure we don't default
+        self.factory.makeOCIProject(pillar=distro)
+        result = distro.getOCIProject(u'first-project')
+        self.assertEqual(first_project, result)
+
 
 class TestDistributionCurrentSourceReleases(
     CurrentSourceReleasesMixin, TestCase):
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 21caa71..66c7f73 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -3434,6 +3434,11 @@ class EditSnapBaseSet(EditByRegistryExpertsOrAdmins):
     usedfor = ISnapBaseSet
 
 
+class ViewOCIProject(AnonymousAuthorization):
+    """Anyone can view an `IOCIProject`."""
+    usedfor = IOCIProject
+
+
 class EditOCIProject(AuthorizationBase):
     permission = 'launchpad.Edit'
     usedfor = IOCIProject