← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:bug-oci-project-navigation into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:bug-oci-project-navigation into launchpad:master with ~pappacena/launchpad:bugtask-oci-project as a prerequisite.

Commit message:
Navigation on the UI for OCI project bugs

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/394181
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:bug-oci-project-navigation into launchpad:master.
diff --git a/lib/lp/app/widgets/launchpadtarget.py b/lib/lp/app/widgets/launchpadtarget.py
index 89b2a95..b2cc50b 100644
--- a/lib/lp/app/widgets/launchpadtarget.py
+++ b/lib/lp/app/widgets/launchpadtarget.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -33,6 +33,7 @@ from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.product import IProduct
 from lp.services.features import getFeatureFlag
 from lp.services.webapp.interfaces import (
@@ -43,11 +44,17 @@ from lp.services.webapp.interfaces import (
 
 @implementer(IAlwaysSubmittedWidget, IMultiLineWidgetLayout, IInputWidget)
 class LaunchpadTargetWidget(BrowserWidget, InputWidget):
-    """Widget for selecting a product, distribution or package target."""
+    """Widget for selecting a product, distribution, package target or OCI
+    project (the last is disabled by default).
+
+    To enable OCIProject as target, subclass LaunchpadTargetWidget and set
+    `show_ociproject` to True.
+    """
 
     template = ViewPageTemplateFile('templates/launchpad-target.pt')
     default_option = "package"
     _widgets_set_up = False
+    show_ociproject = False
 
     def getDistributionVocabulary(self):
         return 'Distribution'
@@ -55,6 +62,9 @@ class LaunchpadTargetWidget(BrowserWidget, InputWidget):
     def getProductVocabulary(self):
         return 'Product'
 
+    def getOCIProjectVocabulary(self):
+        return 'OCIProject'
+
     def setUpSubWidgets(self):
         if self._widgets_set_up:
             return
@@ -76,6 +86,10 @@ class LaunchpadTargetWidget(BrowserWidget, InputWidget):
                 __name__='package', title=u"Package",
                 required=False, vocabulary=package_vocab),
             ]
+        if self.show_ociproject:
+            fields.append(Choice(
+                __name__='ociproject', title=u"OCI project",
+                required=False, vocabulary=self.getOCIProjectVocabulary()))
         self.distribution_widget = CustomWidgetFactory(
             LaunchpadDropdownWidget)
         for field in fields:
@@ -86,7 +100,10 @@ class LaunchpadTargetWidget(BrowserWidget, InputWidget):
     def setUpOptions(self):
         """Set up options to be rendered."""
         self.options = {}
-        for option in ['package', 'product']:
+        option_names = ['package', 'product']
+        if self.show_ociproject:
+            option_names.append('ociproject')
+        for option in option_names:
             attributes = dict(
                 type='radio', name=self.name, value=option,
                 id='%s.option.%s' % (self.name, option))
@@ -131,6 +148,24 @@ class LaunchpadTargetWidget(BrowserWidget, InputWidget):
                         "There is no project named '%s' registered in"
                         " Launchpad" % entered_name))
                 raise self._error
+        if form_value == 'ociproject':
+            try:
+                return self.ociproject_widget.getInputValue()
+            except MissingInputError:
+                self._error = WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        'Please enter an OCI project name'))
+                raise self._error
+            except ConversionError:
+                entered_name = self.request.form_ng.getOne(
+                    "%s.ociproject" % self.name)
+                self._error = WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "There is no OCI project named '%s' registered in"
+                        " Launchpad" % entered_name))
+                raise self._error
         elif form_value == 'package':
             try:
                 distribution = self.distribution_widget.getInputValue()
@@ -186,6 +221,9 @@ class LaunchpadTargetWidget(BrowserWidget, InputWidget):
             self.default_option = 'package'
             self.distribution_widget.setRenderedValue(value.distribution)
             self.package_widget.setRenderedValue(value.sourcepackagename)
+        elif IOCIProject.providedBy(value):
+            self.default_option = 'ociproject'
+            self.ociproject_widget.setRenderedValue(value)
         else:
             raise AssertionError('Not a valid value: %r' % value)
 
diff --git a/lib/lp/app/widgets/templates/launchpad-target.pt b/lib/lp/app/widgets/templates/launchpad-target.pt
index 3efdb81..0d3b6a3 100644
--- a/lib/lp/app/widgets/templates/launchpad-target.pt
+++ b/lib/lp/app/widgets/templates/launchpad-target.pt
@@ -28,11 +28,24 @@
       <label>
         <input type="radio" value="product"
                tal:replace="structure view/options/product" />
-       Project 
+       Project
       </label>
     </td>
     <td>
       <tal:product tal:replace="structure view/product_widget" />
     </td>
   </tr>
+
+  <tr>
+    <td>
+      <label>
+        <input type="radio" value="ociproject"
+               tal:replace="structure view/options/ociproject" />
+       OCI project
+      </label>
+    </td>
+    <td>
+      <tal:product tal:replace="structure view/ociproject_widget" />
+    </td>
+  </tr>
 </table>
diff --git a/lib/lp/bugs/browser/buglisting.py b/lib/lp/bugs/browser/buglisting.py
index 6acbf8a..82c069a 100644
--- a/lib/lp/bugs/browser/buglisting.py
+++ b/lib/lp/bugs/browser/buglisting.py
@@ -113,6 +113,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
 from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.productseries import IProductSeries
@@ -1109,6 +1110,7 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
         distroseries_context = self._distroSeriesContext()
         distrosourcepackage_context = self._distroSourcePackageContext()
         sourcepackage_context = self._sourcePackageContext()
+        ociproject_context = self._ociprojectContext()
 
         if (upstream_context or productseries_context or
             distrosourcepackage_context or sourcepackage_context):
@@ -1121,6 +1123,9 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
             return [
                 "id", "summary", "productname", "importance", "status",
                 "heat"]
+        elif ociproject_context:
+            return [
+                "id", "summary", "ociproject", "importance", "status", "heat"]
         else:
             raise AssertionError(
                 "Unrecognized context; don't know which report "
@@ -1528,15 +1533,20 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
 
     @property
     def structural_subscriber_label(self):
-        if IDistribution.providedBy(self.context):
+        if IOCIProject.providedBy(self.context):
+            target = self.context.pillar
+        else:
+            target = self.context
+
+        if IDistribution.providedBy(target):
             return 'Package or series subscriber'
-        elif IDistroSeries.providedBy(self.context):
+        elif IDistroSeries.providedBy(target):
             return 'Package subscriber'
-        elif IProduct.providedBy(self.context):
+        elif IProduct.providedBy(target):
             return 'Series subscriber'
-        elif IProjectGroup.providedBy(self.context):
+        elif IProjectGroup.providedBy(target):
             return 'Project or series subscriber'
-        elif IPerson.providedBy(self.context):
+        elif IPerson.providedBy(target):
             return 'Project, distribution, package, or series subscriber'
         else:
             return None
@@ -1548,7 +1558,12 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
         """
         # It doesn't make sense to show the target name when viewing product
         # bugs.
-        if IProduct.providedBy(self.context):
+        if IOCIProject.providedBy(self.context):
+            target = self.context.pillar
+        else:
+            target = self.context
+
+        if IProduct.providedBy(target):
             return False
         else:
             return True
@@ -1645,6 +1660,13 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
         """
         return IDistributionSourcePackage(self.context, None)
 
+    def _ociprojectContext(self):
+        """Is this page being viewed in an OCI project context?
+
+        Return the IOCIProject if yes, otherwise return None.
+        """
+        return IOCIProject(self.context, None)
+
     @property
     def addquestion_url(self):
         """Return the URL for the +addquestion view for the context."""
diff --git a/lib/lp/bugs/browser/bugtarget.py b/lib/lp/bugs/browser/bugtarget.py
index e25187c..c59aa5f 100644
--- a/lib/lp/bugs/browser/bugtarget.py
+++ b/lib/lp/bugs/browser/bugtarget.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """IBugTarget-related browser views."""
@@ -127,6 +127,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
 from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.productseries import IProductSeries
@@ -813,7 +814,8 @@ class FileBugViewBase(LaunchpadFormView):
         elif (IDistribution.providedBy(context) or
               IProjectGroup.providedBy(context) or
               IDistroSeries.providedBy(context) or
-              ISourcePackage.providedBy(context)):
+              ISourcePackage.providedBy(context) or
+              IOCIProject.providedBy(context)):
             pass
         else:
             raise TypeError("Unexpected bug target: %r" % context)
diff --git a/lib/lp/bugs/browser/bugtask.py b/lib/lp/bugs/browser/bugtask.py
index 219c9e6..18ccef5 100644
--- a/lib/lp/bugs/browser/bugtask.py
+++ b/lib/lp/bugs/browser/bugtask.py
@@ -154,6 +154,7 @@ from lp.registry.interfaces.distroseries import (
     IDistroSeries,
     IDistroSeriesSet,
     )
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.productseries import IProductSeries
@@ -1655,26 +1656,35 @@ def bugtask_sort_key(bugtask):
     Designed to make sense when bugtargetdisplayname is shown.
     """
     if IDistribution.providedBy(bugtask.target):
-        key = (None, bugtask.target.displayname, None, None, None)
+        key = (None, bugtask.target.displayname, None, None, None, None)
     elif IDistroSeries.providedBy(bugtask.target):
         key = (
             None, bugtask.target.distribution.displayname,
-            bugtask.target.name, None, None)
+            bugtask.target.name, None, None, None)
     elif IDistributionSourcePackage.providedBy(bugtask.target):
         key = (
             bugtask.target.sourcepackagename.name,
-            bugtask.target.distribution.displayname, None, None, None)
+            bugtask.target.distribution.displayname, None, None, None, None)
     elif ISourcePackage.providedBy(bugtask.target):
         key = (
             bugtask.target.sourcepackagename.name,
             bugtask.target.distribution.displayname,
-            Version(bugtask.target.distroseries.version), None, None)
+            Version(bugtask.target.distroseries.version), None, None, None)
     elif IProduct.providedBy(bugtask.target):
-        key = (None, None, None, bugtask.target.displayname, None)
+        key = (None, None, None, bugtask.target.displayname, None, None)
     elif IProductSeries.providedBy(bugtask.target):
         key = (
             None, None, None, bugtask.target.product.displayname,
-            bugtask.target.name)
+            bugtask.target.name, None)
+    elif IOCIProject.providedBy(bugtask.target):
+        ociproject = bugtask.target
+        pillar = ociproject.pillar
+        key = [None, None, None, None, None, ociproject.displayname]
+        if IDistribution.providedBy(pillar):
+            key[1] = pillar.displayname
+        elif IProduct.providedBy(pillar):
+            key[3] = pillar.displayname
+        key = tuple(key)
     else:
         raise AssertionError("No sort key for %r" % bugtask.target)
 
@@ -1993,6 +2003,8 @@ class BugTasksTableView(LaunchpadView):
             # fake one.
             if ISeriesBugTarget.providedBy(bugtask.target):
                 parent = bugtask.target.bugtarget_parent
+            elif IOCIProject.providedBy(bugtask.target):
+                latest_parent = parent = bugtask.target.pillar
             else:
                 latest_parent = parent = bugtask.target
 
diff --git a/lib/lp/bugs/browser/tests/test_bugtarget_filebug.py b/lib/lp/bugs/browser/tests/test_bugtarget_filebug.py
index d3efbe2..5fdcdf1 100644
--- a/lib/lp/bugs/browser/tests/test_bugtarget_filebug.py
+++ b/lib/lp/bugs/browser/tests/test_bugtarget_filebug.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from __future__ import absolute_import, print_function, unicode_literals
@@ -36,6 +36,8 @@ from lp.bugs.interfaces.bugtask import (
     BugTaskStatus,
     )
 from lp.registry.enums import BugSharingPolicy
+from lp.registry.interfaces.ociproject import IOCIProject
+from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.projectgroup import IProjectGroup
 from lp.services.beautifulsoup import BeautifulSoup
 from lp.services.features.testing import FeatureFixture
@@ -375,20 +377,25 @@ class TestFileBugViewBase(FileBugViewMixin, TestCaseWithFactory):
             }]
         self.assertEqual(expected_guidelines, view.bug_reporting_guidelines)
 
-    def filebug_via_view(self, information_type=None,
-                         bug_sharing_policy=None, extra_data_token=None):
+    def filebug_via_view(self, information_type=None, bug_sharing_policy=None,
+                         extra_data_token=None, target=None):
+        if target is None:
+            target = self.factory.makeProduct(official_malone=True)
+        owner = (target.owner if not IOCIProject.providedBy(target)
+                 else target.registrant)
         form = self.get_form()
         if information_type:
             form['field.information_type'] = information_type
-        product = self.factory.makeProduct(official_malone=True)
         if bug_sharing_policy:
-            self.factory.makeCommercialSubscription(product=product)
-            with person_logged_in(product.owner):
-                product.setBugSharingPolicy(bug_sharing_policy)
-        with person_logged_in(product.owner):
+            if not IProduct.providedBy(target):
+                raise ValueError("Only Product supports this.")
+            self.factory.makeCommercialSubscription(product=target)
+            with person_logged_in(owner):
+                target.setBugSharingPolicy(bug_sharing_policy)
+        with person_logged_in(owner):
             view = create_view(
-                product, '+filebug', method='POST', form=form,
-                principal=product.owner)
+                target, '+filebug', method='POST', form=form,
+                principal=owner)
             if extra_data_token is not None:
                 view = view.publishTraverse(view.request, extra_data_token)
             view.initialize()
@@ -396,6 +403,29 @@ class TestFileBugViewBase(FileBugViewMixin, TestCaseWithFactory):
             bug_number = bug_url.split('/')[-1]
             return (getUtility(IBugSet).getByNameOrID(bug_number), view)
 
+    def assertFilesBugForTarget(self, target):
+        bug, view = self.filebug_via_view(target=target)
+        self.assertEqual(
+            InformationType.PUBLIC, view.default_information_type)
+        self.assertEqual([target], [i.target for i in bug.bugtasks])
+        self.assertEqual(InformationType.PUBLIC, bug.information_type)
+
+    def test_filebug_distribution(self):
+        # We should be able to open bugs for distributions.
+        self.assertFilesBugForTarget(self.factory.makeDistribution())
+
+    def test_filebug_ociproject_of_distribution(self):
+        # We should be able to open bugs for oci project of a distribution.
+        distro = self.factory.makeDistribution()
+        self.assertFilesBugForTarget(
+            self.factory.makeOCIProject(pillar=distro))
+
+    def test_filebug_ociproject_of_project(self):
+        # We should be able to open bugs for oci project of a project.
+        product = self.factory.makeProduct()
+        self.assertFilesBugForTarget(
+            self.factory.makeOCIProject(pillar=product))
+
     def test_filebug_default_information_type(self):
         # If we don't specify the bug's information_type, it is PUBLIC.
         bug, view = self.filebug_via_view()
diff --git a/lib/lp/bugs/browser/tests/test_bugtarget_tags.py b/lib/lp/bugs/browser/tests/test_bugtarget_tags.py
index 204861a..311ae8a 100644
--- a/lib/lp/bugs/browser/tests/test_bugtarget_tags.py
+++ b/lib/lp/bugs/browser/tests/test_bugtarget_tags.py
@@ -1,34 +1,67 @@
-# Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 
+from testscenarios.testcase import WithScenarios
+
 from lp.testing import TestCaseWithFactory
 from lp.testing.layers import DatabaseFunctionalLayer
 from lp.testing.views import create_view
 
 
-class TestBugTargetTags(TestCaseWithFactory):
+class TestBugTargetTags(WithScenarios, TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
 
+    scenarios = [
+        ("product + group", {"factory_name": 'makeProductAndGroup'}),
+        ("product", {"factory_name": 'makeProduct'}),
+        ("distribution", {"factory_name": 'makeDistribution'}),
+        ("ociproject of product", {
+            "factory_name": 'makeOCIProjectFromProduct'}),
+        ("ociproject of distro", {"factory_name": 'makeOCIProjectFromDistro'}),
+        ]
+
+    def makeProductAndGroup(self):
+        project_group = self.factory.makeProject()
+        product = self.factory.makeProduct(projectgroup=project_group)
+        return project_group, product
+
+    def makeProduct(self):
+        prod = self.factory.makeProduct()
+        return prod, prod
+
+    def makeDistribution(self):
+        distro = self.factory.makeDistribution()
+        return distro, distro
+
+    def makeOCIProjectFromProduct(self):
+        target = self.factory.makeProduct()
+        ociproject = self.factory.makeOCIProject(pillar=target)
+        return ociproject, ociproject
+
+    def makeOCIProjectFromDistro(self):
+        target = self.factory.makeDistribution()
+        ociproject = self.factory.makeOCIProject(pillar=target)
+        return ociproject, ociproject
+
     def setUp(self):
         super(TestBugTargetTags, self).setUp()
-        self.project_group = self.factory.makeProject()
-        self.target_product = self.factory.makeProduct(
-            projectgroup=self.project_group)
+        builder = getattr(self, self.factory_name)
+        self.view_context, self.bug_target = builder()
 
     def test_no_tags(self):
-        self.factory.makeBug(target=self.target_product)
+        self.factory.makeBug(target=self.bug_target)
         view = create_view(
-            self.project_group,
+            self.view_context,
             name="+bugtarget-portlet-tags-content")
         self.assertEqual([], [tag['tag'] for tag in view.tags_cloud_data])
 
     def test_tags(self):
-        self.factory.makeBug(target=self.target_product, tags=[u'foo'])
+        self.factory.makeBug(target=self.bug_target, tags=[u'foo'])
         view = create_view(
-            self.project_group,
+            self.view_context,
             name="+bugtarget-portlet-tags-content")
         self.assertEqual(
             [u'foo'],
@@ -36,15 +69,15 @@ class TestBugTargetTags(TestCaseWithFactory):
 
     def test_tags_order(self):
         """Test that the tags are ordered by most used first"""
-        self.factory.makeBug(target=self.target_product, tags=[u'tag-last'])
+        self.factory.makeBug(target=self.bug_target, tags=[u'tag-last'])
         for counter in range(0, 2):
             self.factory.makeBug(
-                target=self.target_product, tags=[u'tag-middle'])
+                target=self.bug_target, tags=[u'tag-middle'])
         for counter in range(0, 3):
             self.factory.makeBug(
-                target=self.target_product, tags=[u'tag-first'])
+                target=self.bug_target, tags=[u'tag-first'])
         view = create_view(
-            self.project_group,
+            self.view_context,
             name="+bugtarget-portlet-tags-content")
         self.assertEqual(
             [u'tag-first', u'tag-middle', u'tag-last'],
diff --git a/lib/lp/bugs/browser/tests/test_bugtask.py b/lib/lp/bugs/browser/tests/test_bugtask.py
index cb98e74..8e659d3 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask.py
@@ -657,6 +657,13 @@ class TestBugTasksTableView(TestCaseWithFactory):
         self.view.initialize()
         self.assertIs(None, self.view.getTargetLinkTitle(bug_task.target))
 
+    def test_getTargetLinkTitle_ociproject(self):
+        # The target link title is always none for ociprojects.
+        target = self.factory.makeOCIProject()
+        bug_task = self.factory.makeBugTask(bug=self.bug, target=target)
+        self.view.initialize()
+        self.assertIs(None, self.view.getTargetLinkTitle(bug_task.target))
+
     def test_getTargetLinkTitle_unpublished_distributionsourcepackage(self):
         # The target link title states that the package is not published
         # in the current release.
@@ -782,6 +789,7 @@ class TestBugTasksTableView(TestCaseWithFactory):
         # Product tasks come first, sorted by product then series.
         # Distro tasks follow, sorted by package, distribution, then
         # series (by version in the case of distribution series).
+        # OCI projects comes after their pillars.
         foo = self.factory.makeProduct(displayname='Foo')
         self.factory.makeProductSeries(product=foo, name='2.0')
         self.factory.makeProductSeries(product=foo, name='1.0')
@@ -801,9 +809,14 @@ class TestBugTasksTableView(TestCaseWithFactory):
         foo_spn = self.factory.makeSourcePackageName('foo')
         bar_spn = self.factory.makeSourcePackageName('bar')
 
+        foo_ociproject = self.factory.makeOCIProject(pillar=foo)
+        barix_ociproject = self.factory.makeOCIProject(pillar=barix)
+
         expected_targets = [
             bar, bar.getSeries('0.0'),
-            foo, foo.getSeries('1.0'), foo.getSeries('2.0'),
+            foo, foo_ociproject,
+            foo.getSeries('1.0'), foo.getSeries('2.0'),
+            barix_ociproject,
             barix.getSourcePackage(bar_spn),
             barix.getSeries('beta').getSourcePackage(bar_spn),
             barix.getSeries('aaa-release').getSourcePackage(bar_spn),
@@ -1564,6 +1577,37 @@ class TestDistributionBugs(TestCaseWithFactory, BugTaskViewTestMixin):
         self._assert_shouldShowStructuralSubscriberWidget()
 
 
+class TestOCIProjectOfProductBugs(TestCaseWithFactory, BugTaskViewTestMixin):
+    """Test the bugs overview page for OCI project based on project."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIProjectOfProductBugs, self).setUp()
+        self.initTarget()
+
+    def initTarget(self):
+        product = self.factory.makeProduct()
+        self.target = self.factory.makeOCIProject(pillar=product)
+        self.structural_label_subscriber = 'Series subscriber'
+
+    def test_structural_subscriber_label(self):
+        self._assert_structural_subscriber_label(
+            self.structural_label_subscriber)
+
+    def test_shouldShowStructuralSubscriberWidget(self):
+        self._assert_shouldShowStructuralSubscriberWidget()
+
+
+class TestOCIProjectOfDistributionBugs(TestOCIProjectOfProductBugs):
+    """Test the bugs overview page for OCI project based on distribution."""
+
+    def initTarget(self):
+        distro = self.factory.makeDistribution()
+        self.target = self.factory.makeOCIProject(pillar=distro)
+        self.structural_label_subscriber = 'Package or series subscriber'
+
+
 class TestDistroSeriesBugs(TestCaseWithFactory, BugTaskViewTestMixin):
     """Test the bugs overview page for distro series."""
 
diff --git a/lib/lp/bugs/browser/widgets/bugtask.py b/lib/lp/bugs/browser/widgets/bugtask.py
index 5169abc..f480b0b 100644
--- a/lib/lp/bugs/browser/widgets/bugtask.py
+++ b/lib/lp/bugs/browser/widgets/bugtask.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Widgets related to IBugTask."""
@@ -468,6 +468,7 @@ class BugTaskBugWatchWidget(RadioWidget):
 
 
 class BugTaskTargetWidget(LaunchpadTargetWidget):
+    show_ociproject = True
 
     def getDistributionVocabulary(self):
         distro = self.context.context.distribution
diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
index 3e4544b..8225706 100644
--- a/lib/lp/bugs/configure.zcml
+++ b/lib/lp/bugs/configure.zcml
@@ -1018,6 +1018,9 @@
         factory=".model.structuralsubscription.DistroSeriesTargetHelper"
         permission="zope.Public"/>
     <adapter
+        factory=".model.structuralsubscription.OCIProjectTargetHelper"
+        permission="zope.Public"/>
+    <adapter
         factory=".model.structuralsubscription.ProjectGroupTargetHelper"
         permission="zope.Public"/>
     <adapter
diff --git a/lib/lp/bugs/interfaces/bugtasksearch.py b/lib/lp/bugs/interfaces/bugtasksearch.py
index 736c5e1..37b3b5b 100644
--- a/lib/lp/bugs/interfaces/bugtasksearch.py
+++ b/lib/lp/bugs/interfaces/bugtasksearch.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces for searching bug tasks. Mostly used with IBugTaskSet."""
@@ -172,7 +172,7 @@ class BugTaskSearchParams:
                  created_since=None, exclude_conjoined_tasks=False, cve=None,
                  upstream_target=None, milestone_dateexpected_before=None,
                  milestone_dateexpected_after=None, created_before=None,
-                 information_type=None, ignore_privacy=False):
+                 information_type=None, ignore_privacy=False, ociproject=None):
 
         self.bug = bug
         self.searchtext = searchtext
@@ -223,6 +223,7 @@ class BugTaskSearchParams:
         else:
             self.information_type = None
         self.ignore_privacy = ignore_privacy
+        self.ociproject = ociproject
 
     def setProduct(self, product):
         """Set the upstream context on which to filter the search."""
@@ -281,6 +282,10 @@ class BugTaskSearchParams:
             self.distribution = sourcepackage.distribution
         self.sourcepackagename = sourcepackage.sourcepackagename
 
+    def setOCIProject(self, ociproject):
+        """Set the distribution context on which to filter the search."""
+        self.ociproject = ociproject
+
     def setTarget(self, target):
         """Constrain the search to only return items in target.
 
@@ -303,6 +308,7 @@ class BugTaskSearchParams:
         from lp.registry.interfaces.sourcepackage import ISourcePackage
         from lp.registry.interfaces.distributionsourcepackage import \
             IDistributionSourcePackage
+        from lp.registry.interfaces.ociproject import IOCIProject
         if isinstance(target, (any, all)):
             assert len(target.query_values), \
                 'cannot determine target with no targets'
@@ -325,6 +331,8 @@ class BugTaskSearchParams:
             self.setSourcePackage(target)
         elif IProjectGroup.providedBy(instance):
             self.setProjectGroup(target)
+        elif IOCIProject.providedBy(instance):
+            self.setOCIProject(instance)
         else:
             raise AssertionError("unknown target type %r" % target)
 
@@ -362,7 +370,8 @@ class BugTaskSearchParams:
                        linked_merge_proposals=None, linked_blueprints=None,
                        structural_subscriber=None,
                        modified_since=None, created_since=None,
-                       created_before=None, information_type=None):
+                       created_before=None, information_type=None,
+                       ociproject=None):
         """Create and return a new instance using the parameter list."""
         search_params = cls(user=user, orderby=order_by)
 
@@ -377,6 +386,7 @@ class BugTaskSearchParams:
         search_params.owner = owner
         search_params.affected_user = affected_user
         search_params.distribution = distribution
+        search_params.ociproject = ociproject
         if has_patch:
             # Import this here to avoid circular imports
             from lp.bugs.interfaces.bugattachment import (
diff --git a/lib/lp/bugs/model/bugtask.py b/lib/lp/bugs/model/bugtask.py
index 156b721..62feb1a 100644
--- a/lib/lp/bugs/model/bugtask.py
+++ b/lib/lp/bugs/model/bugtask.py
@@ -157,6 +157,7 @@ def bugtask_sort_key(bugtask):
     distribution_name = ''
     distroseries_name = ''
     sourcepackage_name = ''
+    ociproject = ''
 
     if bugtask.product:
         product_name = bugtask.product.name
@@ -180,7 +181,7 @@ def bugtask_sort_key(bugtask):
 
     return (
         bugtask.bug.id, distribution_name, product_name, productseries_name,
-        distroseries_name, sourcepackage_name)
+        distroseries_name, sourcepackage_name, ociproject)
 
 
 def bug_target_from_key(product, productseries, distribution, distroseries,
diff --git a/lib/lp/bugs/model/bugtasksearch.py b/lib/lp/bugs/model/bugtasksearch.py
index 9ceb517..631c92e 100644
--- a/lib/lp/bugs/model/bugtasksearch.py
+++ b/lib/lp/bugs/model/bugtasksearch.py
@@ -321,6 +321,7 @@ def _build_query(params):
         BugTaskFlat.importance: params.importance,
         BugTaskFlat.product: params.product,
         BugTaskFlat.distribution: params.distribution,
+        BugTaskFlat.ociproject: params.ociproject,
         BugTaskFlat.distroseries: params.distroseries,
         BugTaskFlat.productseries: params.productseries,
         BugTaskFlat.assignee: params.assignee,
diff --git a/lib/lp/bugs/model/structuralsubscription.py b/lib/lp/bugs/model/structuralsubscription.py
index c2834e3..814d165 100644
--- a/lib/lp/bugs/model/structuralsubscription.py
+++ b/lib/lp/bugs/model/structuralsubscription.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -71,6 +71,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
     )
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.milestone import IMilestone
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import (
     validate_person,
     validate_public_person,
@@ -202,6 +203,27 @@ class DistroSeriesTargetHelper:
 
 
 @implementer(IStructuralSubscriptionTargetHelper)
+@adapter(IOCIProject)
+class OCIProjectTargetHelper:
+    """A helper for `OCIProject`s."""
+    target_type_display = 'OCI project'
+
+    def __init__(self, target):
+        self.target = target.pillar
+        self.target_parent = target.pillar
+        self.pillar = target.pillar
+        if IDistribution.providedBy(target.pillar):
+            self.target_arguments = {"distribution": target}
+            self.join = (StructuralSubscription.distribution == target.pillar)
+        elif IProduct.providedBy(target.pillar):
+            self.target_arguments = {"product": target}
+            self.join = (StructuralSubscription.product == target.pillar)
+        else:
+            raise AttributeError(
+                "Invalid pillar for OCIProject subscription: " % target.pillar)
+
+
+@implementer(IStructuralSubscriptionTargetHelper)
 @adapter(IProjectGroup)
 class ProjectGroupTargetHelper:
     """A helper for `IProjectGroup`s."""
diff --git a/lib/lp/bugs/stories/bugtask-management/xx-bugtask-edit-forms.txt b/lib/lp/bugs/stories/bugtask-management/xx-bugtask-edit-forms.txt
index 8ba4163..f4abae5 100644
--- a/lib/lp/bugs/stories/bugtask-management/xx-bugtask-edit-forms.txt
+++ b/lib/lp/bugs/stories/bugtask-management/xx-bugtask-edit-forms.txt
@@ -49,6 +49,7 @@ respective bug task.
     Distribution
     ...
     Project (Find…)
+    OCI project (Find...)
     Status  Importance  Milestone
     New...  Low...      (nothing selected)...
     Assigned to... Mark Shuttleworth (mark)
diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
index 0d23a15..0fd322b 100644
--- a/lib/lp/registry/browser/ociproject.py
+++ b/lib/lp/registry/browser/ociproject.py
@@ -26,6 +26,8 @@ from lp.app.browser.launchpadform import (
     )
 from lp.app.browser.tales import CustomizableFormatter
 from lp.app.errors import NotFoundError
+from lp.app.interfaces.headings import IHeadingBreadcrumb
+from lp.bugs.browser.bugtask import BugTargetTraversalMixin
 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.registry.interfaces.distribution import IDistribution
@@ -115,7 +117,8 @@ class OCIProjectFormatterAPI(CustomizableFormatter):
         return {'displayname': displayname}
 
 
-class OCIProjectNavigation(TargetDefaultVCSNavigationMixin, Navigation):
+class OCIProjectNavigation(TargetDefaultVCSNavigationMixin,
+                           BugTargetTraversalMixin, Navigation):
 
     usedfor = IOCIProject
 
@@ -127,7 +130,7 @@ class OCIProjectNavigation(TargetDefaultVCSNavigationMixin, Navigation):
         return series
 
 
-@implementer(IMultiFacetedBreadcrumb)
+@implementer(IMultiFacetedBreadcrumb, IHeadingBreadcrumb)
 class OCIProjectBreadcrumb(Breadcrumb):
     """Builds a breadcrumb for an `IOCIProject`."""
 
@@ -142,8 +145,23 @@ class OCIProjectFacets(StandardLaunchpadFacets):
     enable_only = [
         'overview',
         'branches',
+        'bugs',
         ]
 
+    def makeLink(self, text, context, view_name, site):
+        site = 'mainsite' if self.mainsite_only else site
+        target = canonical_url(context, view_name=view_name, rootsite=site)
+        return Link(target, text, site=site)
+
+    def branches(self):
+        return self.makeLink('Code', self.context.pillar, '+branches', 'code')
+
+    def bugs(self):
+        """Override bugs link to show the OCIProject's bug page, instead of
+        the pillar's bug page.
+        """
+        return self.makeLink('Bugs', self.context, '+bugs', 'bugs')
+
 
 class OCIProjectNavigationMenu(NavigationMenu):
     """Navigation menu for OCI projects."""
diff --git a/lib/lp/registry/browser/tests/test_ociproject.py b/lib/lp/registry/browser/tests/test_ociproject.py
index 9fb5822..4ec4630 100644
--- a/lib/lp/registry/browser/tests/test_ociproject.py
+++ b/lib/lp/registry/browser/tests/test_ociproject.py
@@ -12,6 +12,7 @@ __all__ = []
 from datetime import datetime
 
 import pytz
+import soupmatchers
 from zope.security.proxy import removeSecurityProxy
 
 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
@@ -83,6 +84,43 @@ class TestOCIProjectView(BrowserTestCase):
 
     layer = DatabaseFunctionalLayer
 
+    def test_facet_top_links(self):
+        oci_project = self.factory.makeOCIProject()
+        browser = self.getViewBrowser(oci_project)
+        menu = soupmatchers.Tag(
+            "facetmenu", "ul", attrs={"class": "facetmenu"})
+
+        with admin_logged_in():
+            # Expected links with (title, link, "<li> element's css classes")
+            expected_links = [
+                ("Overview", None, "overview active"),
+                ("Code", canonical_url(
+                    oci_project.pillar,
+                    view_name="+branches",
+                    rootsite="code"), None),
+                ("Bugs", canonical_url(
+                    oci_project, view_name="+bugs", rootsite="bugs"), None),
+                ("Blueprints", None, "specifications disabled-tab"),
+                ("Translations", None, "translations disabled-tab"),
+                ("Answers", None, "answers disabled-tab"),
+            ]
+
+        tags = []
+        for text, link, css_classes in expected_links:
+            if link:
+                tags.append(soupmatchers.Tag(
+                    text, "a", text=text, attrs={"href": link}))
+            else:
+                tags.append(soupmatchers.Tag(
+                    text, "li",
+                    text=text,
+                    attrs={"class": css_classes}))
+
+        self.assertThat(
+            browser.contents,
+            soupmatchers.HTMLContains(*[
+                soupmatchers.Within(menu, tag) for tag in tags]))
+
     def test_index_distribution_pillar(self):
         distribution = self.factory.makeDistribution(displayname="My Distro")
         oci_project = self.factory.makeOCIProject(
diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
index a189654..36c8cc5 100644
--- a/lib/lp/registry/interfaces/ociproject.py
+++ b/lib/lp/registry/interfaces/ociproject.py
@@ -44,12 +44,17 @@ from zope.schema import (
 from zope.security.interfaces import Unauthorized
 
 from lp import _
+from lp.app.interfaces.launchpad import IServiceUsage
 from lp.app.validators.name import name_validator
 from lp.app.validators.path import path_does_not_escape
 from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
 from lp.bugs.interfaces.bugtarget import (
     IBugTarget,
     IHasExpirableBugs,
+    IHasOfficialBugTags,
+    )
+from lp.bugs.interfaces.structuralsubscription import (
+    IStructuralSubscriptionTarget,
     )
 from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
@@ -67,7 +72,9 @@ from lp.services.fields import (
 OCI_PROJECT_ALLOW_CREATE = 'oci.project.create.enabled'
 
 
-class IOCIProjectView(IHasGitRepositories, Interface):
+class IOCIProjectView(IHasGitRepositories, IServiceUsage,
+                      IHasOfficialBugTags, IStructuralSubscriptionTarget,
+                      Interface):
     """IOCIProject attributes that require launchpad.View permission."""
 
     id = Int(title=_("ID"), required=True, readonly=True)
@@ -96,6 +103,11 @@ class IOCIProjectView(IHasGitRepositories, Interface):
 
     bug_supervisor = Attribute(_("The bug supervisor for this OCI Project."))
 
+    def getAllowedBugInformationTypes():
+        """Get which InformationTypes are allowed for bugs."""
+
+    title = Attribute(_("A title for this OCI project."))
+
     def getSeriesByName(name):
         """Get an OCIProjectSeries for this OCIProject by series' name."""
 
diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
index 4dab589..fdc30ab 100644
--- a/lib/lp/registry/model/ociproject.py
+++ b/lib/lp/registry/model/ociproject.py
@@ -13,7 +13,6 @@ __all__ = [
 
 import pytz
 import six
-from lp.services.database.stormexpr import fti_search
 from six import text_type
 from storm.expr import (
     Join,
@@ -31,7 +30,14 @@ from zope.component import getUtility
 from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.enums import (
+    FREE_INFORMATION_TYPES,
+    ServiceUsage,
+    )
 from lp.bugs.model.bugtarget import BugTargetBase
+from lp.bugs.model.structuralsubscription import (
+    StructuralSubscriptionTargetMixin,
+    )
 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.ociproject import (
@@ -55,6 +61,7 @@ from lp.services.database.interfaces import (
     IStore,
     )
 from lp.services.database.stormbase import StormBase
+from lp.services.database.stormexpr import fti_search
 
 
 def oci_project_modified(oci_project, event):
@@ -69,7 +76,7 @@ def oci_project_modified(oci_project, event):
 
 
 @implementer(IOCIProject)
-class OCIProject(BugTargetBase, StormBase):
+class OCIProject(BugTargetBase, StructuralSubscriptionTargetMixin, StormBase):
     """See `IOCIProject` and `IOCIProjectSet`."""
 
     __storm_table__ = "OCIProject"
@@ -99,6 +106,13 @@ class OCIProject(BugTargetBase, StormBase):
     enable_bugfiling_duplicate_search = Bool(
         name="enable_bugfiling_duplicate_search")
 
+    answers_usage = ServiceUsage.NOT_APPLICABLE
+    blueprints_usage = ServiceUsage.NOT_APPLICABLE
+    codehosting_usage = ServiceUsage.NOT_APPLICABLE
+    translations_usage = ServiceUsage.NOT_APPLICABLE
+    bug_tracking_usage = ServiceUsage.LAUNCHPAD
+    uses_launchpad = True
+
     @property
     def name(self):
         return self.ociprojectname.name
@@ -132,9 +146,17 @@ class OCIProject(BugTargetBase, StormBase):
         return "OCI project %s for %s" % (
             self.ociprojectname.name, self.pillar.display_name)
 
-    displayname = display_name
-    bugtargetname = display_name
-    bugtargetdisplayname = display_name
+    @property
+    def displayname(self):
+        return "%s (%s)" % (self.name, self.pillar.display_name)
+
+    bugtargetname = displayname
+    bugtargetdisplayname = displayname
+    title = displayname
+
+    def _customizeSearchParams(self, search_params):
+        """Customize `search_params` for this OCI project."""
+        search_params.setOCIProject(self)
 
     @property
     def driver(self):
@@ -146,6 +168,23 @@ class OCIProject(BugTargetBase, StormBase):
         """See `IOCIProject`."""
         return self.pillar.bug_supervisor
 
+    def getAllowedBugInformationTypes(self):
+        """See `IDistribution.`"""
+        return FREE_INFORMATION_TYPES
+
+    def getBugSummaryContextWhereClause(self):
+        """See BugTargetBase."""
+        # Circular fail.
+        from lp.bugs.model.bugsummary import BugSummary
+        return BugSummary.ociproject_id == self.id
+
+    def _getOfficialTagClause(self):
+        return self.pillar._getOfficialTagClause()
+
+    @property
+    def official_bug_tags(self):
+        return self.pillar.official_bug_tags
+
     def newRecipe(self, name, registrant, owner, git_ref,
                   build_file, description=None, build_daily=False,
                   require_virtualized=True, build_args=None):

Follow ups