← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~bryceharrington/launchpad/lp-617679-code into lp:launchpad

 

Bryce Harrington has proposed merging lp:~bryceharrington/launchpad/lp-617679-code into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


This implements the interface and model layer for adding tracking of Bugzilla components in launchpad, building on branch lp-617679-db.

This adds two classes BugTrackerComponent and BugTrackerComponentGroup to wrap the corresponding tables.  It also adds a routine to the BugTracker class to allow adding component groups there.

For testing, I've been using this command line:
  ./bin/test -t bugtracker_components

A pre-implementation design meeting was done with Deryck at the Launchpad Epic and I've had several follow up mutter discussions with him about particulars.

One missing part is this does not implement the actual linking of components to source packages, but that bit of functionality was proving difficult to implement so I'm going to leave that to a follow up branch.

-- 
https://code.launchpad.net/~bryceharrington/launchpad/lp-617679-code/+merge/35905
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~bryceharrington/launchpad/lp-617679-code into lp:launchpad.
=== modified file 'database/schema/comments.sql'
--- database/schema/comments.sql	2010-09-17 03:50:51 +0000
+++ database/schema/comments.sql	2010-09-18 01:02:49 +0000
@@ -346,6 +346,21 @@
 COMMENT ON COLUMN BugTrackerPerson.name IS 'The (within the bug tracker) unique username in the external bug tracker.';
 COMMENT ON COLUMN BugTrackerPerson.person IS 'The Person record in Launchpad this user corresponds to.';
 
+-- BugTrackerComponent
+
+COMMENT ON TABLE BugTrackerComponent IS 'A software component in a remote bug tracker, which can be linked to the corresponding source package in a distribution using this table.';
+COMMENT ON COLUMN BugTrackerComponent.name IS 'The name of the component as registered in the remote bug tracker.';
+COMMENT ON COLUMN BugTrackerComponent.is_visible IS 'Whether to display or hide the item in the Launchpad user interface.';
+COMMENT ON COLUMN BugTrackerComponent.is_custom IS 'Whether the item was added by a user in Launchpad or is kept in sync with the remote bug tracker.';
+COMMENT ON COLUMN BugTrackerComponent.component_group IS 'The product or other higher level category used by the remote bug tracker to group projects, if any.';
+COMMENT ON COLUMN BugTrackerComponent.distro_source_package IS 'A link to the source package in a distribution that corresponds to this component.  This can be undefined if no link has been established yet.';
+
+-- BugTrackerComponentGroup
+
+COMMENT ON TABLE BugTrackerComponentGroup IS 'A collection of components as modeled in a remote bug tracker, often referred to as a product.  Some bug trackers do not categorize software components this way, so they will have a single default component group that all components belong to.';
+COMMENT ON COLUMN BugTrackerComponentGroup.name IS 'The product or category name used in the remote bug tracker for grouping components.';
+COMMENT ON COLUMN BugTrackerComponentGroup.bug_tracker IS 'The external bug tracker this component group belongs to.';
+
 -- BugCve
 
 COMMENT ON TABLE BugCve IS 'A table that records the link between a given malone bug number, and a CVE entry.';

=== added file 'database/schema/patch-2208-09-0.sql'
--- database/schema/patch-2208-09-0.sql	1970-01-01 00:00:00 +0000
+++ database/schema/patch-2208-09-0.sql	2010-09-18 01:02:49 +0000
@@ -0,0 +1,38 @@
+-- Copyright 2010 Canonical Ltd.  This software is licensed under the
+-- GNU Affero General Public License version 3 (see the file LICENSE).
+
+SET client_min_messages=ERROR;
+
+CREATE TABLE BugTrackerComponentGroup (
+    id serial PRIMARY KEY,
+    name text NOT NULL,
+    bug_tracker integer NOT NULL REFERENCES BugTracker
+
+    CONSTRAINT valid_name CHECK (valid_name(name))
+);
+
+ALTER TABLE BugTrackerComponentGroup
+    ADD CONSTRAINT bugtrackercomponentgroup__bug_tracker__name__key
+    UNIQUE (bug_tracker, name);
+
+
+CREATE TABLE BugTrackerComponent (
+    id serial PRIMARY KEY,
+    name text NOT NULL,
+    is_visible boolean NOT NULL DEFAULT True,
+    is_custom boolean NOT NULL DEFAULT True,
+    component_group integer NOT NULL REFERENCES BugTrackerComponentGroup,
+    distro_source_package integer REFERENCES DistributionSourcePackage,
+
+    CONSTRAINT valid_name CHECK (valid_name(name))
+);
+
+ALTER TABLE BugTrackerComponent
+    ADD CONSTRAINT bugtrackercomponent__component_group__name__key
+    UNIQUE (component_group, name);
+
+ALTER TABLE BugTrackerComponent
+    ADD CONSTRAINT bugtrackercomponent__distro_source_package__key
+    UNIQUE (distro_source_package);
+
+INSERT INTO LaunchpadDatabaseRevision VALUES(2208, 09, 0);

=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2010-09-17 03:50:51 +0000
+++ database/schema/security.cfg	2010-09-18 01:02:49 +0000
@@ -121,6 +121,8 @@
 public.bugnotificationrecipientarchive  = SELECT, UPDATE
 public.bugtag                           = SELECT, INSERT, DELETE
 public.bugtrackerperson                 = SELECT, UPDATE
+public.bugtrackercomponent              = SELECT, INSERT, UPDATE
+public.bugtrackercomponentgroup         = SELECT, INSERT, UPDATE
 public.bugwatchactivity                 = SELECT, INSERT, UPDATE
 public.codeimport                       = SELECT, INSERT, UPDATE, DELETE
 public.codeimportevent                  = SELECT, INSERT, UPDATE
@@ -542,6 +544,8 @@
 public.bugsubscription                  = SELECT
 public.bugtask                          = SELECT, INSERT, UPDATE
 public.bugtracker                       = SELECT, INSERT
+public.bugtrackercomponent              = SELECT, INSERT, UPDATE, DELETE
+public.bugtrackercomponentgroup         = SELECT, INSERT, UPDATE, DELETE
 public.bugtrackeralias                  = SELECT
 public.bugtrackerperson                 = SELECT, INSERT
 public.bugwatch                         = SELECT, INSERT, UPDATE

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml	2010-09-10 16:21:21 +0000
+++ lib/lp/bugs/configure.zcml	2010-09-18 01:02:49 +0000
@@ -367,10 +367,13 @@
                     aliases
                     baseurl
                     bugtrackertype
+                    componentForDistroSourcePackage
                     contactdetails
+                    getAllRemoteComponentGroups
                     getBugFilingAndSearchLinks
                     getBugsWatching
                     getLinkedPersonByName
+                    getRemoteComponentGroup
                     has_lp_plugin
                     id
                     imported_bug_messages
@@ -393,6 +396,7 @@
                     destroySelf
                     ensurePersonForSelf
                     linkPersonToSelf
+                    addRemoteComponentGroup
                     "
                 set_attributes="
                     aliases
@@ -460,6 +464,44 @@
                 interface="lp.bugs.interfaces.bugtracker.IBugTrackerAliasSet"/>
         </securedutility>
 
+        <!--BugTrackerComponent -->
+
+        <class
+            class="canonical.launchpad.database.BugTrackerComponent">
+            <require
+                permission="zope.Public"
+                attributes="
+                    id
+                    name
+                    is_visible
+                    is_custom
+                    show
+                    hide
+                    setCustom
+                    "/>
+            <implements
+                interface="lp.bugs.interfaces.bugtracker.IBugTrackerComponent"/>
+        </class>
+
+        <!--BugTrackerComponentGroup -->
+
+        <class
+            class="canonical.launchpad.database.BugTrackerComponentGroup">
+            <require
+                permission="zope.Public"
+                attributes="
+                    id
+                    name
+                    bug_tracker
+                    components
+                    getComponent
+                    addComponent
+                    addCustomComponent
+                    "/>
+            <implements
+                interface="lp.bugs.interfaces.bugtracker.IBugTrackerComponentGroup"/>
+        </class>
+
         <!-- RemoteBug -->
 
         <class

=== modified file 'lib/lp/bugs/interfaces/bugtracker.py'
--- lib/lp/bugs/interfaces/bugtracker.py	2010-08-26 20:08:43 +0000
+++ lib/lp/bugs/interfaces/bugtracker.py	2010-09-18 01:02:49 +0000
@@ -12,6 +12,8 @@
     'IBugTracker',
     'IBugTrackerAlias',
     'IBugTrackerAliasSet',
+    'IBugTrackerComponent',
+    'IBugTrackerComponentGroup',
     'IBugTrackerSet',
     'IRemoteBug',
     'SINGLE_PRODUCT_BUGTRACKERTYPES',
@@ -29,6 +31,7 @@
     export_as_webservice_entry,
     export_factory_operation,
     export_read_operation,
+    export_write_operation,
     exported,
     operation_parameters,
     operation_returns_entry,
@@ -353,6 +356,29 @@
             point between now and 24 hours hence.
         """
 
+    @operation_parameters(
+        component_group_name=TextLine(
+            title=u"The name of the remote component group", required=True))
+    @export_write_operation()
+    def addRemoteComponentGroup(component_group_name):
+        """Adds a new component group to the bug tracker"""
+
+    @export_read_operation()
+    def getAllRemoteComponentGroups():
+        """Retrieve all of the registered component groups for bug tracker.
+        """
+
+    @operation_parameters(
+        component_group_name=TextLine(
+            title=u"The name of the remote component group", required=True))
+# TODO: Why can't I specify this as the return type?
+#    @operation_returns_entry(IBugTrackerComponentGroup)
+    @export_read_operation()
+    def getRemoteComponentGroup(component_group_name):
+        """Retrieve a given component group registered with the bug tracker.
+
+        :param component_group_name: Name of the component group to retrieve.
+        """
 
 class IBugTrackerSet(Interface):
     """A set of IBugTracker's.
@@ -457,6 +483,89 @@
         """Query IBugTrackerAliases by BugTracker."""
 
 
+class IBugTrackerComponent(Interface):
+    """The software component in the remote bug tracker.
+
+    Most bug trackers organize bug reports by the software 'component'
+    they affect.  This class provides a mapping of this upstream component
+    to the corresponding source package in the distro.
+    """
+    #TODO: Is this needed?
+    export_as_webservice_entry()
+
+    id = Int(title=_('ID'), required=True, readonly=True)
+    is_visible = Bool(title=_('Is Visible?'),
+                      description=_("Should the component be shown in "
+                                    "the Launchpad web interface?"),
+                      readonly=True)
+    is_custom = Bool(title=_('Is Custom?'),
+                     description=_("Was the component added locally in "
+                                   "Launchpad?  If it was, we must retain "
+                                   "it across updates of bugtracker data."),
+                     readonly=True)
+  
+    # TODO: Should this be BugTrackerNameField(??
+    name = exported(
+        Text(
+            title=_('Name'),
+            constraint=name_validator,
+            description=_('The name of a software component'
+                          'in a remote bug tracker')))
+
+    distro_source_package = exported(
+        Reference(
+            title=_('Distribution Source Package'),
+            schema=Interface,
+            description=_('The distribution source package for this '
+                          'component, if one has been defined.')))
+
+    @export_write_operation()
+    def show():
+        """Cause this component to be shown in the Launchpad web interface"""
+
+    @export_write_operation()
+    def hide():
+        """Cause this component not to be shown in the Launchpad web interface"""
+
+
+class IBugTrackerComponentGroup(Interface):
+    """A collection of components in a remote bug tracker.
+
+    Some bug trackers organize sets of components into higher level groups,
+    such as Bugzilla's 'product'.
+    """
+    #TODO: Is this needed?
+    #export_as_webservice_entry()
+
+    id = Int(title=_('ID'))
+
+    name = exported(
+        Text(
+            title=_('Name'),
+#            constraint=name_validator,
+            description=_('The name of the bug tracker product.')))
+
+    # This probably should be a sql multi-join
+    #components = exported(
+    #    List(
+    #        title=_('Components'),
+    #        description=_(
+    #            'A list of components in the remote bug tracker that '
+    #            'are grouped together in this product'),
+    #        required=False))
+
+    #bug_tracker = exported(
+    #    Reference(title=_('BugTracker'), schema=Interface))
+
+    @operation_parameters(
+        component_name=TextLine(
+            title=u"The name of the remote software component to be added",
+            required=True))
+    @export_write_operation()
+    def addComponent(component_name):
+       """Adds a component to be tracked as part of this component group"""
+
+
 class IRemoteBug(Interface):
     """A remote bug for a given bug tracker."""
 

=== modified file 'lib/lp/bugs/model/bugtracker.py'
--- lib/lp/bugs/model/bugtracker.py	2010-08-26 20:08:43 +0000
+++ lib/lp/bugs/model/bugtracker.py	2010-09-18 01:02:49 +0000
@@ -6,10 +6,12 @@
 __metaclass__ = type
 __all__ = [
     'BugTracker',
+    'BugTrackerSet',
     'BugTrackerAlias',
     'BugTrackerAliasSet',
-    'BugTrackerSet']
-
+    'BugTrackerComponent',
+    'BugTrackerComponentGroup',
+    ]
 
 from datetime import datetime
 from itertools import chain
@@ -21,6 +23,14 @@
     splittype,
     )
 
+from storm.base import Storm
+from storm.expr import And
+from storm.locals import (
+        Int,
+        Reference,
+        ReferenceSet,
+        Unicode,
+        )
 from zope.component import getUtility
 from zope.interface import implements
 from zope.security.interfaces import Unauthorized
@@ -63,6 +73,8 @@
     IBugTracker,
     IBugTrackerAlias,
     IBugTrackerAliasSet,
+    IBugTrackerComponent,
+    IBugTrackerComponentGroup,
     IBugTrackerSet,
     SINGLE_PRODUCT_BUGTRACKERTYPES,
     )
@@ -198,6 +210,9 @@
         'BugWatch', joinColumn='bugtracker', orderBy='-datecreated',
         prejoins=['bug'])
 
+    component_groups = SQLMultipleJoin(
+        'BugTrackerComponentGroup', joinColumn='bug_tracker', orderBy='name')
+
     _filing_url_patterns = {
         BugTrackerType.BUGZILLA: (
             "%(base_url)s/enter_bug.cgi?product=%(remote_product)s"
@@ -515,6 +530,42 @@
             next_check=new_next_check, lastchecked=None,
             last_error_type=None)
 
+    def addRemoteComponentGroup(self, component_group_name):
+        """See `IBugTracker`."""
+        
+        if component_group_name is None:
+            component_group_name = "default"
+        component_group = BugTrackerComponentGroup()
+        component_group.name = component_group_name
+        component_group.bug_tracker = self
+
+        store = IStore(BugTrackerComponentGroup)
+        store.add(component_group)
+        store.commit()
+
+        return component_group
+
+    def getAllRemoteComponentGroups(self):
+        """See `IBugTracker`."""
+        component_groups = []
+
+        component_groups = Store.of(self).find(
+            BugTrackerComponentGroup,
+            BugTrackerComponentGroup.bug_tracker == self.id)
+        component_groups = component_groups.order_by(
+            BugTrackerComponentGroup.name)
+        return component_groups
+
+    def getRemoteComponentGroup(self, component_group_name):
+        """See `IBugTracker`."""
+        component_group = None
+        store = IStore(BugTrackerComponentGroup)
+        component_group = store.find(
+            BugTrackerComponentGroup,
+            name = component_group_name).one()
+        return component_group
+
+
 
 class BugTrackerSet:
     """Implements IBugTrackerSet for a container or set of BugTrackers,
@@ -657,3 +708,104 @@
     def queryByBugTracker(self, bugtracker):
         """See IBugTrackerSet."""
         return self.table.selectBy(bugtracker=bugtracker.id)
+
+
+class BugTrackerComponent(Storm):
+    """The software component in the remote bug tracker.
+
+    Most bug trackers organize bug reports by the software 'component'
+    they affect.  This class provides a mapping of this upstream component
+    to the corresponding source package in the distro.
+    """
+    implements(IBugTrackerComponent)
+    __storm_table__ = 'BugTrackerComponent'
+
+    id = Int(primary=True)
+    name = Unicode(allow_none=False)
+
+    component_group_id = Int('component_group')
+    component_group = Reference(
+        component_group_id,
+        'BugTrackerComponentGroup.id')
+
+    is_visible = Bool(allow_none=False)
+    is_custom = Bool(allow_none=False)
+
+    distro_source_package_id = Int('distro_source_package')
+    distro_source_package = Reference(
+        distro_source_package_id,
+        'DistributionSourcePackageInDatabase.id')
+
+    def show(self):
+        if not self.is_visible:
+            self.is_visible = True
+
+    def hide(self):
+        if self.is_visible:
+            self.is_visible = False
+
+    def setCustom(self):
+        if not self.is_custom:
+            self.is_custom = True
+
+
+class BugTrackerComponentGroup(Storm):
+    """A collection of components in a remote bug tracker.
+
+    Some bug trackers organize sets of components into higher level groups,
+    such as Bugzilla's 'product'.
+    """
+    implements(IBugTrackerComponentGroup)
+    __storm_table__ = 'BugTrackerComponentGroup'
+
+    id = Int(primary=True)
+    name = Unicode(allow_none=False)
+    bug_tracker_id = Int('bug_tracker')
+    bug_tracker = Reference(bug_tracker_id, 'BugTracker.id')
+
+    components = ReferenceSet(
+        id,
+        BugTrackerComponent.component_group_id,
+        order_by=BugTrackerComponent.name)
+
+    def addComponent(self, component_name):
+        """Adds a component that is synced from a remote bug tracker"""
+
+        component = BugTrackerComponent()
+        component.name = component_name
+        component.component_group = self
+
+        store = IStore(BugTrackerComponent)
+        store.add(component)
+        store.commit()
+
+        return component
+
+    def getComponent(self, component_name):
+        """Retrieves a component by the given name.
+
+        None is returned if there is no component by that name in the
+        group.
+        """
+
+        if component_name is None:
+            return None
+        else:
+            return Store.of(self).find(
+                BugTrackerComponent,
+                (BugTrackerComponent.name == component_name)).one()
+                                
+
+    def addCustomComponent(self, component_name):
+        """Adds a component locally that isn't synced from a remote tracker"""
+
+        component = BugTrackerComponent()
+        component.name = component_name 
+        component.component_group = self
+        component.setCustom()
+        
+        store = IStore(BugTrackerComponent)
+        store.add(component)
+        store.commit()
+
+        return component

=== added file 'lib/lp/bugs/tests/test_bugtracker_components.py'
--- lib/lp/bugs/tests/test_bugtracker_components.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/tests/test_bugtracker_components.py	2010-09-18 01:02:49 +0000
@@ -0,0 +1,192 @@
+# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test for components and component groups (products) in bug trackers."""
+
+__metaclass__ = type
+
+__all__ = []
+
+import unittest
+
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+
+from canonical.launchpad.ftests import login_person
+from canonical.testing import DatabaseFunctionalLayer
+from lp.testing import TestCaseWithFactory
+
+from lp.bugs.interfaces.bugtracker import (
+    IBugTracker,
+    IBugTrackerComponent,
+    IBugTrackerComponentGroup)
+
+
+class TestBugTrackerComponent(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestBugTrackerComponent, self).setUp()
+
+        regular_user = self.factory.makePerson()
+        login_person(regular_user)
+
+        self.bug_tracker = self.factory.makeBugTracker()
+        
+        self.comp_group = self.factory.makeBugTrackerComponentGroup(
+            u'alpha',
+            self.bug_tracker)
+
+    def test_component_creation(self):
+        """Verify a component can be created"""
+
+        component = self.factory.makeBugTrackerComponent(
+            u'example', self.comp_group)
+        self.assertTrue(component is not None)
+        self.assertEqual(component.name, u'example')
+
+    def test_set_visibility(self):
+        """Users can delete components
+
+        In case invalid components get imported from a remote bug
+        tracker, users can hide them so they don't show up in the UI.
+        We do this rather than delete them outright so that they won't
+        show up again when we re-sync from the remote bug tracker.
+        """
+
+        component = self.factory.makeBugTrackerComponent(
+            u'example', self.comp_group)
+        self.assertEqual(component.is_visible, True)
+
+        component.hide()
+        self.assertEqual(component.is_visible, False)
+
+        component.show()
+        self.assertEqual(component.is_visible, True)
+
+    def test_custom_component(self):
+        """Users can also add components
+
+        For whatever reason, it may be that we can't import a component
+        from the remote bug tracker.  This gives users a way to correct
+        the omissions."""
+
+        custom_component = self.factory.makeBugTrackerComponent(
+            u'example', self.comp_group, custom=True)
+        self.assertTrue(custom_component != None)
+        self.assertEqual(custom_component.is_custom, True)
+
+    def test_multiple_component_creation(self):
+        """Verify several components can be created at once"""
+        
+        comp_a = self.factory.makeBugTrackerComponent(
+            u'example-a', self.comp_group)
+        comp_b = self.factory.makeBugTrackerComponent(
+            u'example-b', self.comp_group)
+        comp_c = self.factory.makeBugTrackerComponent(
+            u'example-c', self.comp_group, True)
+
+        self.assertTrue(comp_a is not None)
+        self.assertTrue(comp_b is not None)
+        self.assertTrue(comp_c is not None)
+
+
+class TestBugTrackerWithComponents(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestBugTrackerWithComponents, self).setUp()
+
+        regular_user = self.factory.makePerson()
+        login_person(regular_user)
+
+        self.bug_tracker = self.factory.makeBugTracker()
+
+
+    def test_empty_bugtracker(self):
+        """Trivial case of bugtracker with no products or components"""
+
+        self.assertTrue(self.bug_tracker is not None)
+
+        # Empty bugtrackers shouldn't return component groups
+        comp_group = self.bug_tracker.getRemoteComponentGroup(u'non-existant')
+        self.assertEqual(comp_group, None)
+
+        # Verify it contains no component groups
+        comp_groups = self.bug_tracker.getAllRemoteComponentGroups()
+        self.assertEqual(len(list(comp_groups)), 0)
+
+    def test_single_product_bugtracker(self):
+        """Bug tracker with a single (default) product and several components"""
+
+        # Add a component group and fill it with some components
+        default_comp_group = self.bug_tracker.addRemoteComponentGroup(u'alpha')
+        default_comp_group.addComponent(u'example-a')
+        default_comp_group.addComponent(u'example-b')
+        default_comp_group.addComponent(u'example-c')
+
+        # Verify that retrieving an invalid component group returns nothing
+        comp_group = self.bug_tracker.getRemoteComponentGroup(u'non-existant')
+        self.assertEqual(comp_group, None)
+
+        # Now retrieve the component group we added
+        comp_group = self.bug_tracker.getRemoteComponentGroup(u'alpha')
+        self.assertEqual(comp_group, default_comp_group)
+        self.assertEqual(comp_group.name, u'alpha')
+
+        # Verify there is only the one component group in the tracker
+        comp_groups = self.bug_tracker.getAllRemoteComponentGroups()
+        self.assertEqual(len(list(comp_groups)), 1)
+
+    def test_multiple_product_bugtracker(self):
+        """Bug tracker with multiple products and varying numbers of components"""
+
+        # Create several component groups with varying numbers of components
+        comp_group_i = self.bug_tracker.addRemoteComponentGroup(u'alpha')
+
+        comp_group_ii = self.bug_tracker.addRemoteComponentGroup(u'beta')
+        comp_group_ii.addComponent(u'example-beta-1')
+
+        comp_group_iii = self.bug_tracker.addRemoteComponentGroup(u'gamma')
+        comp_group_iii.addComponent(u'example-gamma-1')
+        comp_group_iii.addComponent(u'example-gamma-2')
+        comp_group_iii.addComponent(u'example-gamma-3')
+
+        # Re-verify that retrieving a non-existant component group returns nothing
+        comp_group = self.bug_tracker.getRemoteComponentGroup(u'non-existant')
+        self.assertEqual(comp_group, None)
+
+        # Now retrieve one of the real component groups
+        comp_group = self.bug_tracker.getRemoteComponentGroup(u'beta')
+        self.assertEqual(comp_group, comp_group_ii)
+
+        # Make sure the correct number of component groups are in the bug tracker
+        comp_groups = self.bug_tracker.getAllRemoteComponentGroups()
+        self.assertEqual(len(list(comp_groups)), 3)
+
+    def test_get_components_for_component_group(self):
+        """Retrieve a set of components from a given product"""
+
+        # Create a component group with some components
+        default_comp_group = self.bug_tracker.addRemoteComponentGroup(u'alpha')
+        default_comp_group.addComponent(u'example-a')
+        default_comp_group.addComponent(u'example-b')
+        default_comp_group.addComponent(u'example-c')
+
+        # Retrieve the group, and verify it has the correct number of components
+        comp_group = self.bug_tracker.getRemoteComponentGroup(u'alpha')
+        self.assertEqual(len(list(comp_group.components)), 3)
+
+        # Check one of the components, that it is what we expect
+        comp = comp_group.getComponent(u'example-b')
+        self.assertEqual(comp.name, u'example-b')
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.TestLoader().loadTestsFromName(__name__))
+
+    return suite
+

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2010-09-17 10:44:05 +0000
+++ lib/lp/testing/factory.py	2010-09-18 01:02:49 +0000
@@ -1385,6 +1385,38 @@
         return getUtility(IBugTrackerSet).ensureBugTracker(
             base_url, owner, bugtrackertype, title=title, name=name)
 
+    def makeBugTrackerComponentGroup(self, name=None, bug_tracker=None):
+        """Make a new bug tracker component group."""
+
+        if name is None:
+            name = u'default'
+
+        if bug_tracker is None:
+            bug_tracker = self.makeBugTracker()
+
+        component_group = bug_tracker.addRemoteComponentGroup(name)
+        return component_group
+
+    def makeBugTrackerComponent(self, name=None, component_group=None,
+                                custom=None):
+        """Make a new bug tracker component."""
+
+        if name is None:
+            name = u'default'
+
+        if component_group is None:
+            component_group = self.makeBugTrackerComponentGroup()
+
+        if custom is None:
+            custom = False
+
+        if custom:
+            component = component_group.addCustomComponent(name)
+        else:
+            component = component_group.addComponent(name)
+
+        return component
+
     def makeBugWatch(self, remote_bug=None, bugtracker=None, bug=None,
                      owner=None, bug_task=None):
         """Make a new bug watch."""


Follow ups