← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~bac/launchpad/bug-904335-export-tags into lp:launchpad

 

Brad Crittenden has proposed merging lp:~bac/launchpad/bug-904335-export-tags into lp:launchpad with lp:~frankban/launchpad/view-904335 as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #904335 in Launchpad itself: "Project groups need  a way to aggregate project milestones"
  https://bugs.launchpad.net/launchpad/+bug/904335

For more details, see:
https://code.launchpad.net/~bac/launchpad/bug-904335-export-tags/+merge/87635

Export the getTags and setTags methods to the API.
-- 
https://code.launchpad.net/~bac/launchpad/bug-904335-export-tags/+merge/87635
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~bac/launchpad/bug-904335-export-tags into lp:launchpad.
=== modified file 'database/sampledata/current-dev.sql'
--- database/sampledata/current-dev.sql	2011-12-13 15:21:25 +0000
+++ database/sampledata/current-dev.sql	2012-01-05 15:15:17 +0000
@@ -1,6 +1,6 @@
 -- Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 -- GNU Affero General Public License version 3 (see the file LICENSE).
--- Created using pg_dump (PostgreSQL) 8.4.8
+-- Created using pg_dump (PostgreSQL) 8.4.9
 
 SET check_function_bodies = false;
 SET client_encoding = 'UTF8';
@@ -5752,6 +5752,13 @@
 ALTER TABLE messagechunk ENABLE TRIGGER ALL;
 
 
+ALTER TABLE milestonetag DISABLE TRIGGER ALL;
+
+
+
+ALTER TABLE milestonetag ENABLE TRIGGER ALL;
+
+
 ALTER TABLE mirror DISABLE TRIGGER ALL;
 
 

=== modified file 'database/sampledata/current.sql'
--- database/sampledata/current.sql	2011-12-13 15:21:25 +0000
+++ database/sampledata/current.sql	2012-01-05 15:15:17 +0000
@@ -1,6 +1,6 @@
 -- Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 -- GNU Affero General Public License version 3 (see the file LICENSE).
--- Created using pg_dump (PostgreSQL) 8.4.8
+-- Created using pg_dump (PostgreSQL) 8.4.9
 
 SET check_function_bodies = false;
 SET client_encoding = 'UTF8';
@@ -5682,6 +5682,13 @@
 ALTER TABLE messagechunk ENABLE TRIGGER ALL;
 
 
+ALTER TABLE milestonetag DISABLE TRIGGER ALL;
+
+
+
+ALTER TABLE milestonetag ENABLE TRIGGER ALL;
+
+
 ALTER TABLE mirror DISABLE TRIGGER ALL;
 
 

=== modified file 'database/schema/comments.sql'
--- database/schema/comments.sql	2011-11-17 13:06:30 +0000
+++ database/schema/comments.sql	2012-01-05 15:15:17 +0000
@@ -2425,3 +2425,8 @@
 'OpenId Identifiers that can be used to log into an Account.';
 COMMENT ON COLUMN OpenIdIdentifier.identifier IS
 'OpenId Identifier. This should be a URL, but is currently just a token that can be used to generate the Identity URL for the Canonical SSO OpenId Provider.';
+
+-- MilestoneTag
+COMMENT ON TABLE milestonetag IS 'Attaches simple text tags to a milestone.';
+COMMENT ON COLUMN milestonetag.milestone IS 'The milestone the tag is attached to.';
+COMMENT ON COLUMN milestonetag.tag IS 'The text representation of the tag.';

=== added file 'database/schema/patch-2209-00-3.sql'
--- database/schema/patch-2209-00-3.sql	1970-01-01 00:00:00 +0000
+++ database/schema/patch-2209-00-3.sql	2012-01-05 15:15:17 +0000
@@ -0,0 +1,22 @@
+-- Copyright 2011 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 milestonetag (
+    id SERIAL PRIMARY KEY,
+    milestone integer NOT NULL REFERENCES milestone ON DELETE CASCADE,
+    tag text NOT NULL,
+    date_created timestamp without time zone DEFAULT
+        timezone('UTC'::text, now()) NOT NULL,
+    created_by integer NOT NULL REFERENCES person,
+    CONSTRAINT valid_tag CHECK (valid_name(tag))
+);
+
+ALTER TABLE ONLY milestonetag
+    ADD CONSTRAINT milestonetag__tag__milestone__key UNIQUE (tag, milestone);
+
+CREATE INDEX milestonetag__milestones_idx
+    ON milestonetag USING btree (milestone);
+
+INSERT INTO LaunchpadDatabaseRevision VALUES (2209, 0, 3);

=== added file 'database/schema/patch-2209-00-4.sql'
--- database/schema/patch-2209-00-4.sql	1970-01-01 00:00:00 +0000
+++ database/schema/patch-2209-00-4.sql	2012-01-05 15:15:17 +0000
@@ -0,0 +1,57 @@
+SET client_min_messages=ERROR;
+
+CREATE OR REPLACE FUNCTION check_email_address_person_account(
+    person integer, account integer)
+    RETURNS boolean
+    LANGUAGE plpythonu IMMUTABLE RETURNS NULL ON NULL INPUT AS
+$$
+    # It's possible for an EmailAddress to be created without an
+    # account. If that happens, and this function is called, we return
+    # True so as to avoid breakages.
+    if account is None:
+        return True
+    results = plpy.execute("""
+        SELECT account FROM Person WHERE id = %s""" % person)
+    # If there are no accounts with that Person in the DB, or the Person
+    # is new and hasn't yet been linked to an account, return success
+    # anyway. This helps avoid the PGRestore breaking (and referential
+    # integrity will prevent this from causing bugs later.
+    if results.nrows() == 0 or results[0]['account'] is None:
+        return True
+    return results[0]['account'] == account
+$$;
+
+COMMENT ON FUNCTION check_email_address_person_account(integer, integer) IS
+'Check that the person to which an email address is linked has the same account as that email address.';
+
+CREATE OR REPLACE FUNCTION check_person_email_address_account(
+    person integer, account integer)
+    RETURNS boolean
+    LANGUAGE plpythonu IMMUTABLE RETURNS NULL ON NULL INPUT AS
+$$
+    # It's possible for a Person to be created without an account. If
+    # that happens, return True so that things don't break.
+    if account is None:
+        return True
+    email_address_accounts = plpy.execute("""
+        SELECT account FROM EmailAddress WHERE
+            person = %s AND account IS NOT NULL""" % person)
+    # If there are no email address accounts to check, we're done.
+    if email_address_accounts.nrows() == 0:
+        return True
+    for email_account_row in email_address_accounts:
+        email_account = email_account_row['account']
+        if email_account is not None and email_account != account:
+            return False
+    return True
+$$;
+
+COMMENT ON FUNCTION check_person_email_address_account(integer, integer) IS
+'Check that the email addresses linked to a person have the same account ID as that person.';
+
+ALTER TABLE EmailAddress ADD CONSTRAINT valid_account_for_person
+    CHECK (check_email_address_person_account(person, account));
+ALTER TABLE Person ADD CONSTRAINT valid_account_for_emailaddresses
+    CHECK (check_person_email_address_account(id, account));
+
+INSERT INTO LaunchpadDatabaseRevision VALUES (2209, 00, 4);

=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2012-01-04 11:49:08 +0000
+++ database/schema/security.cfg	2012-01-05 15:15:17 +0000
@@ -18,6 +18,8 @@
 public.bug_update_latest_patch_uploaded(integer) =
 public.bugnotificationarchive              =
 public.calculate_bug_heat(integer)         = EXECUTE
+public.check_email_address_person_account(integer, integer) = EXECUTE
+public.check_person_email_address_account(integer, integer) = EXECUTE
 public.cursor_fetch(refcursor, integer)    = EXECUTE
 public.databasediskutilization             =
 public.debversion(character)                           = EXECUTE
@@ -221,6 +223,7 @@
 public.messageapproval                  = SELECT, INSERT, UPDATE, DELETE
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT, INSERT, UPDATE, DELETE
+public.milestonetag                     = SELECT, INSERT, UPDATE, DELETE
 public.mirrorcdimagedistroseries        = SELECT, INSERT, DELETE
 public.mirrordistroarchseries           = SELECT, INSERT, DELETE, UPDATE
 public.mirrordistroseriessource         = SELECT, INSERT, UPDATE, DELETE
@@ -444,6 +447,7 @@
 public.pofile                           = SELECT, UPDATE
 public.potemplate                       = SELECT, UPDATE
 public.job                              = SELECT, UPDATE, DELETE
+public.packaging                        = SELECT
 public.pofilestatsjob                   = SELECT, UPDATE, DELETE
 public.potmsgset                        = SELECT
 public.product                          = SELECT
@@ -591,6 +595,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.person                           = SELECT, INSERT, UPDATE
 public.personlanguage                   = SELECT
 public.personsettings                   = SELECT, INSERT
@@ -649,6 +654,7 @@
 public.karmaaction                        = SELECT
 public.message                            = SELECT, INSERT
 public.messagechunk                       = SELECT, INSERT
+public.milestonetag                       = SELECT
 public.person                             = SELECT
 public.revision                           = SELECT, INSERT, UPDATE
 public.revisionauthor                     = SELECT, INSERT, UPDATE
@@ -862,6 +868,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.packagecopyjob                   = SELECT, INSERT, DELETE
 public.packagecopyrequest               = SELECT, INSERT, UPDATE
 public.packagediff                      = SELECT, INSERT, UPDATE
@@ -1301,6 +1308,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.packagebuild                     = SELECT, INSERT, UPDATE
 public.packagecopyjob                   = SELECT, INSERT
 public.packagediff                      = SELECT, INSERT, UPDATE, DELETE
@@ -1404,6 +1412,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.packagebuild                     = SELECT, INSERT, UPDATE
 public.packagecopyjob                   = SELECT, INSERT, UPDATE
 public.packagediff                      = SELECT, UPDATE
@@ -1502,6 +1511,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.person                           = SELECT
 public.personlanguage                   = SELECT
 public.personsettings                   = SELECT
@@ -1706,6 +1716,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT, INSERT, DELETE
 public.packageset                       = SELECT
 public.packagesetgroup                  = SELECT
 public.packagesetinclusion              = SELECT
@@ -1946,6 +1957,7 @@
 public.job                              = SELECT, INSERT, UPDATE
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
+public.milestonetag                     = SELECT
 public.person                           = SELECT, INSERT
 public.personsettings                   = SELECT, INSERT
 public.product                          = SELECT, INSERT, UPDATE
@@ -2044,6 +2056,7 @@
 public.message                          = SELECT, UPDATE
 public.messageapproval                  = SELECT, UPDATE
 public.milestone                        = SELECT, UPDATE
+public.milestonetag                     = SELECT, INSERT, UPDATE, DELETE
 public.mirror                           = SELECT, UPDATE
 public.nameblacklist                    = SELECT, UPDATE
 public.oauthaccesstoken                 = SELECT, UPDATE
@@ -2147,6 +2160,7 @@
 public.job                              = SELECT, INSERT, DELETE
 public.logintoken                       = SELECT, DELETE
 public.mailinglistsubscription          = SELECT, DELETE
+public.milestonetag                     = SELECT
 public.oauthnonce                       = SELECT, DELETE
 public.openidconsumerassociation        = SELECT, DELETE
 public.openidconsumernonce              = SELECT, DELETE

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2011-12-30 08:03:42 +0000
+++ lib/lp/bugs/browser/bugtask.py	2012-01-05 15:15:17 +0000
@@ -285,6 +285,7 @@
 from lp.services.webapp.menu import structured
 
 
+
 vocabulary_registry = getVocabularyRegistry()
 
 DISPLAY_BUG_STATUS_FOR_PATCHES = {
@@ -3504,7 +3505,7 @@
               IDistributionSourcePackage.providedBy(self.context)):
             search_params.setSourcePackage(self.context)
         else:
-            raise AssertionError('Uknown context type: %s' % self.context)
+            raise AssertionError('Unknown context type: %s' % self.context)
 
         return u"".join("%d\n" % bug_id for bug_id in
             getUtility(IBugTaskSet).searchBugIds(search_params))

=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
--- lib/lp/bugs/interfaces/bugtask.py	2012-01-03 15:56:55 +0000
+++ lib/lp/bugs/interfaces/bugtask.py	2012-01-05 15:15:17 +0000
@@ -1193,9 +1193,9 @@
 
     def __init__(self, user, bug=None, searchtext=None, fast_searchtext=None,
                  status=None, importance=None, milestone=None,
-                 assignee=None, sourcepackagename=None, owner=None,
-                 attachmenttype=None, orderby=None, omit_dupes=False,
-                 subscriber=None, component=None,
+                 milestone_tag=None, assignee=None, sourcepackagename=None,
+                 owner=None, attachmenttype=None, orderby=None,
+                 omit_dupes=False, subscriber=None, component=None,
                  pending_bugwatch_elsewhere=False, resolved_upstream=False,
                  open_upstream=False, has_no_upstream_bugtask=False, tag=None,
                  has_cve=False, bug_supervisor=None, bug_reporter=None,
@@ -1218,6 +1218,7 @@
         self.status = status
         self.importance = importance
         self.milestone = milestone
+        self.milestone_tag = milestone_tag
         self.assignee = assignee
         self.sourcepackagename = sourcepackagename
         self.owner = owner

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2012-01-02 17:10:14 +0000
+++ lib/lp/bugs/model/bugtask.py	2012-01-05 15:15:17 +0000
@@ -117,6 +117,7 @@
     IMilestoneSet,
     IProjectGroupMilestone,
     )
+from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
 from lp.registry.interfaces.person import (
     IPerson,
     validate_person,
@@ -2052,6 +2053,13 @@
         if params.status is not None:
             extra_clauses.append(self._buildStatusClause(params.status))
 
+        if (params.exclude_conjoined_tasks and
+            not (params.milestone or params.milestone_tag)):
+            raise ValueError(
+                "BugTaskSearchParam.exclude_conjoined cannot be True if "
+                "BugTaskSearchParam.milestone or "
+                "BugTaskSearchParam.milestone_tag is not set")
+
         if params.milestone:
             if IProjectGroupMilestone.providedBy(params.milestone):
                 where_cond = """
@@ -2071,10 +2079,29 @@
                     params.milestone)
                 join_tables += tables
                 extra_clauses += clauses
-        elif params.exclude_conjoined_tasks:
-            raise ValueError(
-                "BugTaskSearchParam.exclude_conjoined cannot be True if "
-                "BugTaskSearchParam.milestone is not set")
+
+        if params.milestone_tag:
+            where_cond = """
+                IN (SELECT Milestone.id
+                    FROM Milestone, Product, MilestoneTag
+                    WHERE Milestone.product = Product.id
+                        AND Product.project = %s
+                        AND MilestoneTag.milestone = Milestone.id
+                        AND MilestoneTag.tag IN %s
+                    GROUP BY Milestone.id
+                    HAVING COUNT(Milestone.id) = %s)
+            """ % sqlvalues(params.milestone_tag.target,
+                            params.milestone_tag.tags,
+                            len(params.milestone_tag.tags))
+            extra_clauses.append("BugTask.milestone %s" % where_cond)
+
+            # XXX frankban 2011-12-16 further investigation needed
+            # to make sure we can skip the _buildExcludeConjoinedClause call
+            # if params.exclude_conjoined_tasks:
+            #     tables, clauses = self._buildExcludeConjoinedClause(
+            #         params.milestone_tag)
+            #     join_tables += tables
+            #     extra_clauses += clauses
 
         if params.project:
             # Prevent circular import problems.
@@ -2872,12 +2899,18 @@
             result[row[:-1]] = row[-1]
         return result
 
-    def getPrecachedNonConjoinedBugTasks(self, user, milestone):
+    def getPrecachedNonConjoinedBugTasks(self, user, milestone_data):
         """See `IBugTaskSet`."""
-        params = BugTaskSearchParams(
-            user, milestone=milestone,
-            orderby=['status', '-importance', 'id'],
-            omit_dupes=True, exclude_conjoined_tasks=True)
+        kwargs = {
+            'orderby': ['status', '-importance', 'id'],
+            'omit_dupes': True,
+            'exclude_conjoined_tasks': True,
+            }
+        if IProjectGroupMilestoneTag.providedBy(milestone_data):
+            kwargs['milestone_tag'] = milestone_data
+        else:
+            kwargs['milestone'] = milestone_data
+        params = BugTaskSearchParams(user, **kwargs)
         return self.search(params)
 
     def createTask(self, bug, owner, target,

=== modified file 'lib/lp/hardwaredb/doc/hwdb.txt'
--- lib/lp/hardwaredb/doc/hwdb.txt	2012-01-04 23:49:46 +0000
+++ lib/lp/hardwaredb/doc/hwdb.txt	2012-01-05 15:15:17 +0000
@@ -109,7 +109,7 @@
 Limitations:
   * "No name" products like mainboards from companies like ASRock
      or Asus that are directly sold to end users have fingerprints like
-     "American Megatrends Inc. Uknown 1.0".
+     "American Megatrends Inc. Unknown 1.0".
   * A manufacturer may erroneously assign identical DMI values for product
     and vendor to different systems.
   * submissions for "counterfeit systems".
@@ -743,4 +743,3 @@
     >>> set(submission.owner.name for submission
     ...     in owner.hardware_submissions)
     set([u'name12'])
-

=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml	2011-12-24 17:49:30 +0000
+++ lib/lp/registry/browser/configure.zcml	2012-01-05 15:15:17 +0000
@@ -1279,17 +1279,22 @@
             MilestoneNavigation"/>
     <adapter
         provides="lp.services.webapp.interfaces.IBreadcrumb"
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         factory="lp.registry.browser.milestone.MilestoneBreadcrumb"
         permission="zope.Public"/>
     <browser:defaultView
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         name="+index"/>
     <browser:url
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         path_expression="string:+milestone/${name}"
         rootsite="mainsite"
         attribute_to_parent="target"/>
+    <browser:url
+        for="lp.registry.interfaces.milestonetag.IProjectGroupMilestoneTag"
+        path_expression="string:+tags/${name}"
+        rootsite="mainsite"
+        attribute_to_parent="target"/>
     <browser:page
         for="*"
         name="+milestone-macros"
@@ -1297,7 +1302,7 @@
         template="../templates/milestone-macros.pt"
         class="lp.app.browser.launchpad.Macro"/>
     <browser:pages
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         class="lp.registry.browser.milestone.MilestoneView"
         permission="zope.Public">
         <browser:page
@@ -1322,6 +1327,20 @@
         class="lp.registry.browser.milestone.MilestoneWithoutCountsView"
         permission="zope.Public"
         template="../templates/productseries-milestone-table-row.pt"/>
+    <browser:pages
+        for="lp.registry.interfaces.milestonetag.IProjectGroupMilestoneTag"
+        class="lp.registry.browser.milestone.MilestoneTagView"
+        permission="zope.Public">
+        <browser:page
+            name="+index"
+            template="../templates/milestone-index.pt"/>
+
+        <!-- Project Group Milestone Tag Portlets -->
+
+        <browser:page
+            name="+portlet-milestone-tag-search"
+            template="../templates/milestone-tag-search.pt"/>
+    </browser:pages>
     <browser:page
         name="+edit"
         for="lp.registry.interfaces.milestone.IMilestone"

=== modified file 'lib/lp/registry/browser/milestone.py'
--- lib/lp/registry/browser/milestone.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/browser/milestone.py	2012-01-05 15:15:17 +0000
@@ -6,6 +6,7 @@
 __metaclass__ = type
 
 __all__ = [
+    'ISearchMilestoneTagsForm',
     'MilestoneAddView',
     'MilestoneBreadcrumb',
     'MilestoneContextMenu',
@@ -15,9 +16,12 @@
     'MilestoneNavigation',
     'MilestoneOverviewNavigationMenu',
     'MilestoneSetNavigation',
+    'MilestoneTagView',
     'MilestoneWithoutCountsView',
     'MilestoneView',
+    'MilestoneViewMixin',
     'ObjectMilestonesView',
+    'validate_tags',
     ]
 
 
@@ -27,7 +31,7 @@
     implements,
     Interface,
     )
-from zope.schema import Choice
+from zope.schema import Choice, TextLine
 
 from lp import _
 from lp.app.browser.launchpadform import (
@@ -35,7 +39,9 @@
     custom_widget,
     LaunchpadEditFormView,
     LaunchpadFormView,
+    safe_action,
     )
+from lp.app.validators.name import valid_name
 from lp.app.widgets.date import DateWidget
 from lp.bugs.browser.bugtask import BugTaskListingItem
 from lp.bugs.browser.structuralsubscription import (
@@ -52,12 +58,16 @@
 from lp.registry.browser.product import ProductDownloadFileMixin
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.milestone import (
+    IAbstractMilestone,
     IMilestone,
+    IMilestoneData,
     IMilestoneSet,
     IProjectGroupMilestone,
     )
+from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import IProduct
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     canonical_url,
@@ -84,16 +94,16 @@
 class MilestoneNavigation(Navigation,
     StructuralSubscriptionTargetTraversalMixin):
     """The navigation to traverse to a milestone."""
-    usedfor = IMilestone
+    usedfor = IMilestoneData
 
 
 class MilestoneBreadcrumb(Breadcrumb):
-    """The Breadcrumb for an `IMilestone`."""
+    """The Breadcrumb for an `IMilestoneData`."""
 
     @property
     def text(self):
-        milestone = IMilestone(self.context)
-        if milestone.code_name:
+        milestone = IMilestoneData(self.context)
+        if hasattr(milestone, 'code_name') and milestone.code_name:
             return '%s "%s"' % (milestone.name, milestone.code_name)
         else:
             return milestone.name
@@ -139,7 +149,7 @@
 
 class MilestoneContextMenu(ContextMenu, MilestoneLinkMixin):
     """The menu for this milestone."""
-    usedfor = IMilestone
+    usedfor = IMilestoneData
 
     @cachedproperty
     def links(self):
@@ -150,8 +160,8 @@
 
 
 class MilestoneOverviewNavigationMenu(NavigationMenu, MilestoneLinkMixin):
-    """Overview navigation menu for `IMilestone` objects."""
-    usedfor = IMilestone
+    """Overview navigation menu for `IAbstractMilestone` objects."""
+    usedfor = IAbstractMilestone
     facet = 'overview'
 
     @cachedproperty
@@ -165,7 +175,7 @@
     """Overview  menus for `IMilestone` objects."""
     # This menu must not contain 'subscribe' because the link state is too
     # costly to calculate when this menu is used with a list of milestones.
-    usedfor = IMilestone
+    usedfor = IMilestoneData
     facet = 'overview'
     links = ('edit', 'create_release')
 
@@ -181,38 +191,8 @@
     links = ('edit', )
 
 
-class MilestoneView(LaunchpadView, ProductDownloadFileMixin):
-    """A View for listing milestones and releases."""
-    # XXX sinzui 2009-05-29 bug=381672: Extract the BugTaskListingItem rules
-    # to a mixin so that MilestoneView and others can use it.
-    implements(IMilestoneInline)
-    show_series_context = False
-
-    def __init__(self, context, request):
-        """See `LaunchpadView`.
-
-        This view may be used with a milestone or a release. The milestone
-        and release (if it exists) are accessible as attributes. The context
-        attribute will always be the milestone.
-
-        :param context: `IMilestone` or `IProductRelease`.
-        :param request: `ILaunchpadRequest`.
-        """
-        super(MilestoneView, self).__init__(context, request)
-        if IMilestone.providedBy(context):
-            self.milestone = context
-            self.release = context.product_release
-        else:
-            self.milestone = context.milestone
-            self.release = context
-        self.context = self.milestone
-
-    def initialize(self):
-        """See `LaunchpadView`."""
-        self.form = self.request.form
-        self.processDeleteFiles()
-        expose_structural_subscription_data_to_js(
-            self.context, self.request, self.user)
+class MilestoneViewMixin(object):
+    """Common methods shared between MilestoneView and MilestoneTagView."""
 
     @property
     def expire_cache_minutes(self):
@@ -232,38 +212,19 @@
         """Return the HTML page title."""
         return self.context.title
 
-    def getReleases(self):
-        """See `ProductDownloadFileMixin`."""
-        return set([self.release])
-
-    @cachedproperty
-    def download_files(self):
-        """The release's files as DownloadFiles."""
-        if self.release is None or self.release.files.count() == 0:
-            return None
-        return list(self.release.files)
-
-    # Listify and cache the specifications, ProductReleaseFiles and bugtasks
-    # to avoid making the same query over and over again when evaluating in
-    # the template.
+    # Listify and cache the specifications and bugtasks to avoid making
+    # the same query over and over again when evaluating in the template.
     @cachedproperty
     def specifications(self):
         """The list of specifications targeted to this milestone."""
         return list(self.context.specifications)
 
     @cachedproperty
-    def product_release_files(self):
-        """Files associated with this milestone."""
-        return list(self.release.files)
-
-    @cachedproperty
     def _bugtasks(self):
         """The list of non-conjoined bugtasks targeted to this milestone."""
         # Put the results in a list so that iterating over it multiple
         # times in this method does not make multiple queries.
-        non_conjoined_slaves = list(
-            getUtility(IBugTaskSet).getPrecachedNonConjoinedBugTasks(
-                self.user, self.context))
+        non_conjoined_slaves = self.context.bugtasks(self.user)
         # Checking bug permissions is expensive. We know from the query that
         # the user has at least launchpad.View on the bugtasks and their bugs.
         # NB: this is in principle unneeded due to injection of permission in
@@ -359,6 +320,83 @@
             return all_assignments
         return all_assignments
 
+    @property
+    def is_project_milestone_tag(self):
+        """Check, if the current milestone is a project milestone tag.
+
+        Return true, if the current milestone is a project milestone tag,
+        else return False."""
+        return IProjectGroupMilestoneTag.providedBy(self.context)
+
+    @property
+    def is_project_milestone(self):
+        """Check, if the current milestone is a project milestone.
+
+        Return true, if the current milestone is a project milestone or
+        a project milestone tag, else return False."""
+        return (
+            IProjectGroupMilestone.providedBy(self.context) or
+            self.is_project_milestone_tag
+            )
+
+    @property
+    def has_bugs_or_specs(self):
+        """Does the milestone have any bugtasks and specifications?"""
+        return len(self.bugtasks) > 0 or len(self.specifications) > 0
+
+
+class MilestoneView(
+    LaunchpadView, MilestoneViewMixin, ProductDownloadFileMixin):
+    """A View for listing milestones and releases."""
+    # XXX sinzui 2009-05-29 bug=381672: Extract the BugTaskListingItem rules
+    # to a mixin so that MilestoneView and others can use it.
+    implements(IMilestoneInline)
+    show_series_context = False
+
+    def __init__(self, context, request):
+        """See `LaunchpadView`.
+
+        This view may be used with a milestone or a release. The milestone
+        and release (if it exists) are accessible as attributes. The context
+        attribute will always be the milestone.
+
+        :param context: `IMilestone` or `IProductRelease`.
+        :param request: `ILaunchpadRequest`.
+        """
+        super(MilestoneView, self).__init__(context, request)
+        if IMilestoneData.providedBy(context):
+            self.milestone = context
+            self.release = context.product_release
+        else:
+            self.milestone = context.milestone
+            self.release = context
+        self.context = self.milestone
+
+    def initialize(self):
+        """See `LaunchpadView`."""
+        self.form = self.request.form
+        self.processDeleteFiles()
+        expose_structural_subscription_data_to_js(
+            self.context, self.request, self.user)
+
+    def getReleases(self):
+        """See `ProductDownloadFileMixin`."""
+        return set([self.release])
+
+    @cachedproperty
+    def download_files(self):
+        """The release's files as DownloadFiles."""
+        if self.release is None or self.release.files.count() == 0:
+            return None
+        return list(self.release.files)
+
+    # Listify and cache ProductReleaseFiles to avoid making the same query
+    # over and over again when evaluating in the template.
+    @cachedproperty
+    def product_release_files(self):
+        """Files associated with this milestone."""
+        return list(self.release.files)
+
     @cachedproperty
     def total_downloads(self):
         """Total downloads of files associated with this milestone."""
@@ -373,19 +411,6 @@
         """
         return IDistroSeries.providedBy(self.context.series_target)
 
-    @property
-    def is_project_milestone(self):
-        """Check, if the current milestone is a project milestone.
-
-        Return true, if the current milestone is a project milestone,
-        else return False."""
-        return IProjectGroupMilestone.providedBy(self.context)
-
-    @property
-    def has_bugs_or_specs(self):
-        """Does the milestone have any bugtasks and specifications?"""
-        return len(self.bugtasks) > 0 or len(self.specifications) > 0
-
 
 class MilestoneWithoutCountsView(MilestoneView):
     """Show a milestone in a list of milestones."""
@@ -533,6 +558,57 @@
         self.next_url = canonical_url(series)
 
 
+def validate_tags(tags):
+    """Check that `separator` separated `tags` are valid tag names."""
+    return (
+        all(valid_name(tag) for tag in tags) and
+        len(set(tags)) == len(tags)
+        )
+
+
+class ISearchMilestoneTagsForm(Interface):
+    """Schema for the search milestone tags form."""
+
+    tags = TextLine(
+        title=_('Search by tags'),
+        description=_('Insert space separated tag names'),
+        required=True, min_length=2, max_length=64,
+        constraint=lambda value: validate_tags(value.split()))
+
+
+class MilestoneTagView(
+    LaunchpadFormView, MilestoneViewMixin, ProductDownloadFileMixin):
+    """A View for listing bugtasks and specification for milestone tags."""
+    schema = ISearchMilestoneTagsForm
+
+    def __init__(self, context, request):
+        """See `LaunchpadView`.
+
+        :param context: `IProjectGroupMilestoneTag`
+        :param request: `ILaunchpadRequest`.
+        """
+        super(MilestoneTagView, self).__init__(context, request)
+        self.context = self.milestone = context
+        self.release = None
+
+    @property
+    def tags(self):
+        """Return a list of tag names associated with current milestonetag."""
+        return self.context.name.split(u',')
+
+    @property
+    def initial_values(self):
+        """Set the initial value of the search tags field."""
+        return {'tags': u' '.join(self.tags)}
+
+    @safe_action
+    @action(u'Search Milestone Tags', name='search')
+    def search_by_tags(self, action, data):
+        tags = data['tags'].split()
+        milestone_tag = ProjectGroupMilestoneTag(self.context.target, tags)
+        self.next_url = canonical_url(milestone_tag, request=self.request)
+
+
 class ObjectMilestonesView(LaunchpadView):
     """A view for listing the milestones for any `IHasMilestones` object"""
 

=== modified file 'lib/lp/registry/browser/project.py'
--- lib/lp/registry/browser/project.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/browser/project.py	2012-01-05 15:15:17 +0000
@@ -30,6 +30,7 @@
     'ProjectView',
     ]
 
+
 from z3c.ptcompat import ViewPageTemplateFile
 from zope.app.form.browser import TextWidget
 from zope.component import getUtility
@@ -75,6 +76,7 @@
     IRegistryCollectionNavigationMenu,
     RegistryCollectionActionMenuBase,
     )
+from lp.registry.browser.milestone import validate_tags
 from lp.registry.browser.objectreassignment import ObjectReassignmentView
 from lp.registry.browser.product import (
     ProductAddView,
@@ -87,6 +89,7 @@
     IProjectGroupSeries,
     IProjectGroupSet,
     )
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
 from lp.services.feeds.browser import FeedsMixin
 from lp.services.fields import (
     PillarAliases,
@@ -130,6 +133,12 @@
     def traverse_series(self, series_name):
         return self.context.getSeries(series_name)
 
+    @stepthrough('+tags')
+    def traverse_tags(self, name):
+        tags = name.split(u',')
+        if validate_tags(tags):
+            return ProjectGroupMilestoneTag(self.context, tags)
+
 
 class ProjectSetNavigation(Navigation):
 
@@ -388,6 +397,11 @@
         """
         return self.context.products.count() > 10
 
+    @property
+    def project_group_milestone_tag(self):
+        """Return a ProjectGroupMilestoneTag based on this project."""
+        return ProjectGroupMilestoneTag(self.context, [])
+
 
 class ProjectEditView(LaunchpadEditFormView):
     """View class that lets you edit a Project object."""

=== modified file 'lib/lp/registry/browser/tests/test_milestone.py'
--- lib/lp/registry/browser/tests/test_milestone.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/browser/tests/test_milestone.py	2012-01-05 15:15:17 +0000
@@ -13,6 +13,7 @@
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.bugs.interfaces.bugtask import IBugTaskSet
 from lp.registry.interfaces.person import TeamSubscriptionPolicy
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
 from lp.services.config import config
 from lp.services.webapp import canonical_url
 from lp.testing import (
@@ -430,3 +431,73 @@
         self.assertThat(self.milestone, browses_under_limit)
         self.add_bug(10)
         self.assertThat(self.milestone, browses_under_limit)
+
+
+class TestMilestoneTagView(TestQueryCountBase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestMilestoneTagView, self).setUp()
+        self.tags = [u'tag1']
+        self.owner = self.factory.makePerson()
+        self.project_group = self.factory.makeProject(owner=self.owner)
+        self.product = self.factory.makeProduct(
+            name="product1",
+            owner=self.owner,
+            project=self.project_group)
+        self.milestone = self.factory.makeMilestone(product=self.product)
+        with person_logged_in(self.owner):
+            self.milestone.setTags(self.tags, self.owner)
+        self.milestonetag = ProjectGroupMilestoneTag(
+            target=self.project_group, tags=self.tags)
+
+    def add_bug(self, count):
+        with person_logged_in(self.owner):
+            for n in range(count):
+                self.factory.makeBug(
+                    product=self.product, owner=self.owner,
+                    milestone=self.milestone)
+
+    def _make_form(self, tags):
+        return {
+            u'field.actions.search': u'Search',
+            u'field.tags': u' '.join(tags)
+            }
+
+    def _url_tail(self, url, separator='/'):
+        return url.rsplit(separator, 1)[1],
+
+    def test_view_properties(self):
+        # Ensure that the view is correctly initialized.
+        view = create_initialized_view(self.milestonetag, '+index')
+        self.assertEqual(self.milestonetag, view.context)
+        self.assertEqual(self.milestonetag.title, view.page_title)
+        self.assertContentEqual(self.tags, view.tags)
+
+    def test_view_form_redirect(self):
+        # Ensure a correct redirection is performed when tags are searched.
+        tags = [u'tag1', u'tag2']
+        form = self._make_form(tags)
+        view = create_initialized_view(self.milestonetag, '+index', form=form)
+        self.assertEqual(302, view.request.response.getStatus())
+        new_milestonetag = ProjectGroupMilestoneTag(
+            target=self.project_group, tags=tags)
+        self.assertEqual(
+            self._url_tail(canonical_url(new_milestonetag)),
+            self._url_tail(view.request.response.getHeader('Location')))
+
+    def test_view_form_error(self):
+        # Ensure the form correctly handles invalid submissions.
+        tags = [u'tag1', u't']  # One char tag is not valid.
+        form = self._make_form(tags)
+        view = create_initialized_view(self.milestonetag, '+index', form=form)
+        self.assertEqual(1, len(view.errors))
+        self.assertEqual('tags', view.errors[0].field_name)
+
+    def test_buktask_query_count(self):
+        # Ensure that a correct number of queries is executed for
+        # bugtasks retrieval.
+        bugtask_count = 10
+        self.assert_bugtasks_query_count(
+            self.milestonetag, bugtask_count, query_limit=11)

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2011-12-30 08:03:42 +0000
+++ lib/lp/registry/configure.zcml	2012-01-05 15:15:17 +0000
@@ -404,8 +404,8 @@
                 setAliases"/>
 
         <!-- IProjectGroupModerate -->
-	<allow
-	    interface="lp.registry.interfaces.projectgroup.IProjectGroupModerate"/>
+        <allow
+            interface="lp.registry.interfaces.projectgroup.IProjectGroupModerate"/>
         <require
             permission="launchpad.Moderate"
             set_schema="lp.registry.interfaces.projectgroup.IProjectGroupModerate"/>
@@ -1006,6 +1006,7 @@
                 series_target
                 displayname
                 title
+                bugtasks
                 specifications
                 product_release"/>
         <require
@@ -1013,7 +1014,14 @@
             attributes="
                 createProductRelease
                 closeBugsAndBlueprints
-                destroySelf"/>
+                destroySelf
+		        setTags
+		        "/>
+        <require
+            permission="zope.Public"
+            attributes="
+                getTags
+                "/>
         <allow interface="lp.bugs.interfaces.bugsummary.IBugSummaryDimension"/>
         <allow
             interface="lp.bugs.interfaces.bugtarget.IHasBugs"/>
@@ -1052,6 +1060,11 @@
         <allow
             interface="lp.registry.interfaces.milestone.IProjectGroupMilestone"/>
     </class>
+    <class
+        class="lp.registry.model.milestonetag.ProjectGroupMilestoneTag">
+        <allow
+            interface="lp.registry.interfaces.milestonetag.IProjectGroupMilestoneTag"/>
+    </class>
     <subscriber
         for="lp.registry.interfaces.product.IProduct zope.lifecycleevent.interfaces.IObjectModifiedEvent"
         handler="lp.registry.subscribers.product_modified"/>
@@ -1245,7 +1258,7 @@
         <!-- https://lists.ubuntu.com/mailman/private/launchpad/2007-April/015189.html
                         for further discussion - stub 20070411 -->
 
-	<!-- Per bug 588773, changing to launchpad.Moderate to allow Registry Experts (~registry) -->
+        <!-- Per bug 588773, changing to launchpad.Moderate to allow Registry Experts (~registry) -->
         <require
             permission="launchpad.Moderate"
             set_attributes="name autoupdate registrant"/>

=== added file 'lib/lp/registry/help/milestone-tags.html'
--- lib/lp/registry/help/milestone-tags.html	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/help/milestone-tags.html	2012-01-05 15:15:17 +0000
@@ -0,0 +1,33 @@
+<html>
+  <head>
+    <title>What is a milestone tag?</title>
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssreset/reset.css" />
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssfonts/fonts.css" />
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssbase/base.css" />
+  </head>
+  <body>
+    <h1>What is a milestone tag?</h1>
+
+    <p>
+      Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
+      tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
+      quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
+      consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
+      cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
+      proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+    </p>
+
+    <p>
+     Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
+     tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
+     quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
+     consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
+     cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
+     proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+    </p>
+
+  </body>
+</html>

=== modified file 'lib/lp/registry/interfaces/milestone.py'
--- lib/lp/registry/interfaces/milestone.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/interfaces/milestone.py	2012-01-05 15:15:17 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0211,E0213
@@ -8,9 +8,11 @@
 __metaclass__ = type
 
 __all__ = [
+    'IAbstractMilestone',
     'ICanGetMilestonesDirectly',
     'IHasMilestones',
     'IMilestone',
+    'IMilestoneData',
     'IMilestoneSet',
     'IProjectGroupMilestone',
     ]
@@ -23,6 +25,7 @@
     export_factory_operation,
     export_operation_as,
     export_read_operation,
+    export_write_operation,
     exported,
     operation_for_version,
     operation_parameters,
@@ -42,6 +45,7 @@
     Bool,
     Choice,
     Int,
+    List,
     TextLine,
     )
 
@@ -97,20 +101,52 @@
         return milestone
 
 
-class IMilestone(IHasBugs, IStructuralSubscriptionTarget,
-                 IHasOfficialBugTags):
-    """A milestone, or a targeting point for bugs and other
-    release-management items that need coordination.
+class IMilestoneData(IHasBugs, IStructuralSubscriptionTarget,
+                     IHasOfficialBugTags):
+    """Interface containing the data for milestones.
+
+    To be registered for views but not instantiated.
     """
-    export_as_webservice_entry()
-
     id = Int(title=_("Id"))
+
     name = exported(
         MilestoneNameField(
             title=_("Name"),
             description=_(
                 "Only letters, numbers, and simple punctuation are allowed."),
             constraint=name_validator))
+    target = exported(
+        Reference(
+            schema=Interface,  # IHasMilestones
+            title=_(
+                "The product, distribution, or project group for this "
+                "milestone."),
+            required=False))
+    specifications = Attribute(
+        "A list of specifications targeted to this object.")
+    dateexpected = exported(
+        FormattableDate(title=_("Date Targeted"), required=False,
+             description=_("Example: 2005-11-24")),
+        exported_as='date_targeted')
+    active = exported(
+        Bool(
+            title=_("Active"),
+            description=_("Whether or not this object should be shown "
+                          "in web forms for targeting.")),
+        exported_as='is_active')
+    displayname = Attribute("A displayname constructed from the name.")
+    title = exported(
+        TextLine(title=_("A context title for pages."),
+                 readonly=True))
+
+    def bugtasks(user):
+        """Get a list of non-conjoined bugtasks visible to this user."""
+
+
+class IAbstractMilestone(IMilestoneData):
+    """An intermediate interface for milestone, or a targeting point for bugs
+    and other release-management items that need coordination.
+    """
     code_name = exported(
         NoneableTextLine(
             title=u'Code name', required=False,
@@ -126,47 +162,24 @@
         title=_("Product Series"),
         description=_("The product series for which this is a milestone."),
         vocabulary="FilteredProductSeries",
-        required=False) # for now
+        required=False)  # for now
     distroseries = Choice(
         title=_("Distro Series"),
         description=_(
             "The distribution series for which this is a milestone."),
         vocabulary="FilteredDistroSeries",
-        required=False) # for now
-    dateexpected = exported(
-        FormattableDate(title=_("Date Targeted"), required=False,
-             description=_("Example: 2005-11-24")),
-        exported_as='date_targeted')
-    active = exported(
-        Bool(
-            title=_("Active"),
-            description=_("Whether or not this milestone should be shown "
-                          "in web forms for bug targeting.")),
-        exported_as='is_active')
+        required=False)  # for now
     summary = exported(
         NoneableDescription(
             title=_("Summary"),
             required=False,
             description=_(
                 "A summary of the features and status of this milestone.")))
-    target = exported(
-        Reference(
-            schema=Interface, # IHasMilestones
-            title=_("The product or distribution of this milestone."),
-            required=False))
     series_target = exported(
         Reference(
-            schema=Interface, # IHasMilestones
+            schema=Interface,  # IHasMilestones
             title=_("The productseries or distroseries of this milestone."),
             required=False))
-    displayname = Attribute("A displayname for this milestone, constructed "
-        "from the milestone name.")
-    title = exported(
-        TextLine(title=_("A milestone context title for pages."),
-                 readonly=True))
-    specifications = Attribute("A list of the specifications targeted to "
-        "this milestone.")
-
     product_release = exported(
         Reference(
             schema=IProductRelease,
@@ -211,6 +224,35 @@
         release.
         """
 
+
+class IMilestone(IAbstractMilestone):
+    """Actual interface for milestones."""
+
+    export_as_webservice_entry()
+
+    @operation_parameters(
+        tags=List(
+            title=_("Tags for this milestone"),
+            description=_("Space-separated keywords for classifying "
+                          "this milestone."),
+            value_type=TextLine()))
+
+    @call_with(user=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version('devel')
+    def setTags(tags, user):
+        """Set the milestone tags.
+
+        :param: tags The list of tags to be associated with milestone.
+        :param: user The user who is updating tags for this milestone.
+        """
+
+    @export_read_operation()
+    @operation_for_version('devel')
+    def getTags():
+        """Return the milestone tags in alphabetical order."""
+
+
 # Avoid circular imports
 IBugTask['milestone'].schema = IMilestone
 patch_plain_parameter_type(
@@ -249,8 +291,9 @@
         """Return all visible milestones."""
 
 
-class IProjectGroupMilestone(IMilestone):
+class IProjectGroupMilestone(IAbstractMilestone):
     """A marker interface for milestones related to a project"""
+    export_as_webservice_entry()
 
 
 class IHasMilestones(Interface):

=== added file 'lib/lp/registry/interfaces/milestonetag.py'
--- lib/lp/registry/interfaces/milestonetag.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/interfaces/milestonetag.py	2012-01-05 15:15:17 +0000
@@ -0,0 +1,20 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""MilestoneTag interfaces."""
+
+__metaclass__ = type
+__all__ = [
+    'IProjectGroupMilestoneTag',
+    ]
+
+
+from lp.registry.interfaces.milestone import IMilestoneData
+
+
+class IProjectGroupMilestoneTag(IMilestoneData):
+    """An IProjectGroupMilestoneTag is a tag aggretating milestones for the
+    ProjectGroup with a given tag or tags.
+
+    This interface is just a marker.
+    """

=== modified file 'lib/lp/registry/model/milestone.py'
--- lib/lp/registry/model/milestone.py	2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/milestone.py	2012-01-05 15:15:17 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0611,W0212
@@ -8,6 +8,7 @@
 __all__ = [
     'HasMilestonesMixin',
     'Milestone',
+    'MilestoneData',
     'MilestoneSet',
     'ProjectMilestone',
     'milestone_sort_key',
@@ -49,6 +50,7 @@
 from lp.registry.interfaces.milestone import (
     IHasMilestones,
     IMilestone,
+    IMilestoneData,
     IMilestoneSet,
     IProjectGroupMilestone,
     )
@@ -129,11 +131,47 @@
         super(MultipleProductReleases, self).__init__(msg)
 
 
-class Milestone(SQLBase, StructuralSubscriptionTargetMixin, HasBugsBase):
+class MilestoneData:
+    implements(IMilestoneData)
+
+    @property
+    def displayname(self):
+        """See IMilestone."""
+        return "%s %s" % (self.target.displayname, self.name)
+
+    @property
+    def title(self):
+        raise NotImplementedError
+
+    @property
+    def specifications(self):
+        raise NotImplementedError
+
+    def bugtasks(self, user):
+        """The list of non-conjoined bugtasks targeted to this milestone."""
+        # Put the results in a list so that iterating over it multiple
+        # times in this method does not make multiple queries.
+        non_conjoined_slaves = list(
+            getUtility(IBugTaskSet).getPrecachedNonConjoinedBugTasks(
+                user, self))
+        return non_conjoined_slaves
+
+
+class Milestone(SQLBase, MilestoneData, StructuralSubscriptionTargetMixin,
+                HasBugsBase):
     implements(IHasBugs, IMilestone, IBugSummaryDimension)
 
+    active = BoolCol(notNull=True, default=True)
+
+    # XXX: EdwinGrubbs 2009-02-06 bug=326384:
+    # The Milestone.dateexpected should be changed into a date column,
+    # since the class defines the field as a DateCol, so that a list of
+    # milestones can't have some dateexpected attributes that are
+    # datetimes and others that are dates, which can't be compared.
+    dateexpected = DateCol(notNull=False, default=None)
+
     # XXX: Guilherme Salgado 2007-03-27 bug=40978:
-    # Milestones should be associated with productseries/distroseriess
+    # Milestones should be associated with productseries/distroseries
     # so these columns are not needed.
     product = ForeignKey(dbName='product',
         foreignKey='Product', default=None)
@@ -145,23 +183,23 @@
     distroseries = ForeignKey(dbName='distroseries',
         foreignKey='DistroSeries', default=None)
     name = StringCol(notNull=True)
-    # XXX: EdwinGrubbs 2009-02-06 bug=326384:
-    # The Milestone.dateexpected should be changed into a date column,
-    # since the class defines the field as a DateCol, so that a list of
-    # milestones can't have some dateexpected attributes that are
-    # datetimes and others that are dates, which can't be compared.
-    dateexpected = DateCol(notNull=False, default=None)
-    active = BoolCol(notNull=True, default=True)
     summary = StringCol(notNull=False, default=None)
     code_name = StringCol(dbName='codename', notNull=False, default=None)
 
-    # joins
     specifications = SQLMultipleJoin('Specification', joinColumn='milestone',
         orderBy=['-priority', 'definition_status',
                  'implementation_status', 'title'],
         prejoins=['assignee'])
 
     @property
+    def target(self):
+        """See IMilestone."""
+        if self.product:
+            return self.product
+        elif self.distribution:
+            return self.distribution
+
+    @property
     def product_release(self):
         store = Store.of(self)
         result = store.find(ProductRelease,
@@ -173,14 +211,6 @@
             return releases[0]
 
     @property
-    def target(self):
-        """See IMilestone."""
-        if self.product:
-            return self.product
-        elif self.distribution:
-            return self.distribution
-
-    @property
     def series_target(self):
         """See IMilestone."""
         if self.productseries:
@@ -189,11 +219,6 @@
             return self.distroseries
 
     @property
-    def displayname(self):
-        """See IMilestone."""
-        return "%s %s" % (self.target.displayname, self.name)
-
-    @property
     def title(self):
         """See IMilestone."""
         if not self.code_name:
@@ -255,6 +280,39 @@
         from lp.bugs.model.bugsummary import BugSummary
         return BugSummary.milestone_id == self.id
 
+    def setTags(self, tags, user):
+        """See IMilestone."""
+        # Circular reference prevention.
+        from lp.registry.model.milestonetag import MilestoneTag
+        store = Store.of(self)
+        if tags:
+            current_tags = set(self.getTags())
+            new_tags = set(tags)
+            if new_tags == current_tags:
+                return
+            # Removing deleted tags.
+            to_remove = current_tags.difference(new_tags)
+            if to_remove:
+                store.find(
+                    MilestoneTag, MilestoneTag.tag.is_in(to_remove)).remove()
+            # Adding new tags.
+            for tag in new_tags.difference(current_tags):
+                store.add(MilestoneTag(self, tag, user))
+        else:
+            store.find(
+                MilestoneTag, MilestoneTag.milestone_id == self.id).remove()
+        store.commit()
+
+    def getTags(self):
+        """See IMilestone."""
+        # Circular reference prevention.
+        from lp.registry.model.milestonetag import MilestoneTag
+        store = Store.of(self)
+        return list(store.find(
+            MilestoneTag, MilestoneTag.milestone_id == self.id
+            ).order_by(MilestoneTag.tag
+            ).values(MilestoneTag.tag))
+
 
 class MilestoneSet:
     implements(IMilestoneSet)
@@ -300,7 +358,7 @@
         return Milestone.selectBy(active=True, orderBy='id')
 
 
-class ProjectMilestone(HasBugsBase):
+class ProjectMilestone(MilestoneData, HasBugsBase):
     """A virtual milestone implementation for project.
 
     The current database schema has no formal concept of milestones related to
@@ -315,12 +373,13 @@
     implements(IProjectGroupMilestone)
 
     def __init__(self, target, name, dateexpected, active):
-        self.name = name
         self.code_name = None
         # The id is necessary for generating a unique memcache key
         # in a page template loop. The ProjectMilestone.id is passed
         # in as the third argument to the "cache" TALes.
         self.id = 'ProjectGroup:%s/Milestone:%s' % (target.name, name)
+        self.name = name
+        self.target = target
         self.code_name = None
         self.product = None
         self.distribution = None
@@ -329,7 +388,6 @@
         self.product_release = None
         self.dateexpected = dateexpected
         self.active = active
-        self.target = target
         self.series_target = None
         self.summary = None
 

=== added file 'lib/lp/registry/model/milestonetag.py'
--- lib/lp/registry/model/milestonetag.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/model/milestonetag.py	2012-01-05 15:15:17 +0000
@@ -0,0 +1,97 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Milestonetag model class."""
+
+__metaclass__ = type
+__all__ = [
+    'MilestoneTag',
+    'ProjectGroupMilestoneTag',
+    ]
+
+
+from zope.interface import implements
+from zope.component import getUtility
+
+from lp.services.webapp.interfaces import (
+    IStoreSelector,
+    MAIN_STORE,
+    DEFAULT_FLAVOR,
+    )
+
+from lp.blueprints.model.specification import Specification
+from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
+from lp.registry.model.milestone import MilestoneData, Milestone
+from lp.registry.model.product import Product
+from storm.locals import (
+    DateTime,
+    Int,
+    Unicode,
+    Reference,
+    )
+
+
+class MilestoneTag(object):
+    """A tag belonging to a milestone."""
+
+    __storm_table__ = 'milestonetag'
+
+    id = Int(primary=True)
+    milestone_id = Int(name='milestone', allow_none=False)
+    milestone = Reference(milestone_id, 'milestone.id')
+    tag = Unicode(allow_none=False)
+    created_by_id = Int(name='created_by', allow_none=False)
+    created_by = Reference(created_by_id, 'person.id')
+    date_created = DateTime(allow_none=False)
+
+    def __init__(self, milestone, tag, created_by, date_created=None):
+        self.milestone_id = milestone.id
+        self.tag = tag
+        self.created_by_id = created_by.id
+        if date_created is not None:
+            self.date_created = date_created
+
+
+class ProjectGroupMilestoneTag(MilestoneData):
+
+    implements(IProjectGroupMilestoneTag)
+
+    def __init__(self, target, tags):
+        self.target = target
+        # Tags is a sequence of Unicode strings.
+        self.tags = tags
+        self.active = True
+        self.dateexpected = None
+
+    @property
+    def name(self):
+        return u','.join(self.tags)
+
+    @property
+    def displayname(self):
+        """See IMilestone."""
+        return "%s %s" % (self.target.displayname, u", ".join(self.tags))
+
+    @property
+    def title(self):
+        """See IMilestoneData."""
+        return self.displayname
+
+    @property
+    def specifications(self):
+        """See IMilestoneData."""
+        store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
+        results = []
+        for tag in self.tags:
+            result = store.find(
+                Specification,
+                Specification.milestone == Milestone.id,
+                Milestone.product == Product.id,
+                Product.project == self.target,
+                MilestoneTag.milestone_id == Milestone.id,
+                MilestoneTag.tag == tag)
+            results.append(result)
+        result = results.pop()
+        for i in results:
+            result = result.intersection(i)
+        return result

=== modified file 'lib/lp/registry/model/projectgroup.py'
--- lib/lp/registry/model/projectgroup.py	2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/projectgroup.py	2012-01-05 15:15:17 +0000
@@ -464,19 +464,19 @@
     @property
     def milestones(self):
         """See `IProjectGroup`."""
-        return self._getMilestones(True)
+        return self._getMilestones(only_active=True)
 
     @property
     def product_milestones(self):
         """Hack to avoid the ProjectMilestone in MilestoneVocabulary."""
         # XXX: bug=644977 Robert Collins - this is a workaround for
-        # insconsistency in project group milestone use.
+        # inconsistency in project group milestone use.
         return self._get_milestones()
 
     @property
     def all_milestones(self):
         """See `IProjectGroup`."""
-        return self._getMilestones(False)
+        return self._getMilestones(only_active=False)
 
     def getMilestone(self, name):
         """See `IProjectGroup`."""

=== modified file 'lib/lp/registry/stories/project/xx-project-index.txt'
--- lib/lp/registry/stories/project/xx-project-index.txt	2011-12-08 19:18:27 +0000
+++ lib/lp/registry/stories/project/xx-project-index.txt	2012-01-05 15:15:17 +0000
@@ -34,7 +34,6 @@
     Gnome Applets
     NetApplet
     gnomebaker
-    ...
 
 The projects are linked.
 

=== modified file 'lib/lp/registry/templates/milestone-index.pt'
--- lib/lp/registry/templates/milestone-index.pt	2011-10-12 00:21:07 +0000
+++ lib/lp/registry/templates/milestone-index.pt	2012-01-05 15:15:17 +0000
@@ -39,11 +39,15 @@
       milestone_menu view/milestone/menu:overview;
       has_edit context/required:launchpad.Edit">
 
+      <tal:search_by_tags_form
+        content="structure context/@@+portlet-milestone-tag-search"
+        condition="view/is_project_milestone_tag"/>
+
       <div class="top-portlet">
-        <div id="description" tal:condition="view/milestone/summary">
+        <div id="description" tal:condition="view/milestone/summary|nothing">
           <p class="documentDescription"
             tal:content="structure view/milestone/summary/fmt:text-to-html">
-              Milestone.summary.
+              Milestone summary.
           </p>
         </div>
       </div>
@@ -67,11 +71,12 @@
               </dl>
 
               <dl id="version">
-                <dt>Version:</dt>
+                <dt tal:condition="view/is_project_milestone_tag">Tags:</dt>
+                <dt tal:condition="not: view/is_project_milestone_tag">Version:</dt>
                 <dd><tal:version replace="context/name" /></dd>
               </dl>
 
-              <dl id="code-name">
+              <dl id="code-name" tal:condition="view/milestone/code_name|nothing">
                 <dt>Code name:</dt>
                 <dd>
                   <tal:code-name replace="view/milestone/code_name" />
@@ -80,40 +85,42 @@
                 </dd>
               </dl>
 
-              <dl tal:condition="not: view/release">
-                <dt>Expected:</dt>
-                <dd><span
-                  tal:attributes="title context/dateexpected/fmt:datetime"
-                  tal:content="context/dateexpected/fmt:approximatedate" />
-                  &nbsp;
-                  <a tal:replace="structure milestone_menu/create_release/fmt:icon-link" />
-                </dd>
-              </dl>
-
-              <tal:has_release condition="view/release">
-                <dl>
-                  <dt>Released:</dt>
+              <tal:is_not_project_milestone_tag condition="not: view/is_project_milestone_tag">
+                <dl tal:condition="not: view/release">
+                  <dt>Expected:</dt>
                   <dd><span
-                    tal:attributes="title view/release/datereleased/fmt:datetime"
-                    tal:content="view/release/datereleased/fmt:approximatedate" />
+                    tal:attributes="title context/dateexpected/fmt:datetime"
+                    tal:content="context/dateexpected/fmt:approximatedate" />
                     &nbsp;
-                    <a tal:replace="structure release_menu/delete/fmt:icon" />
-                    <a tal:replace="structure release_menu/edit/fmt:icon" />
-                    </dd>
-                </dl>
-
-                <dl>
-                  <dt>Registrant:</dt>
-                  <dd><tal:registrant replace="structure view/release/owner/fmt:link"/></dd>
-                </dl>
-
-                <dl>
-                  <dt>Release registered:</dt>
-                  <dd><span
-                    tal:attributes="title view/release/datecreated/fmt:datetime"
-                    tal:content="view/release/datecreated/fmt:approximatedate" /></dd>
-                </dl>
-              </tal:has_release>
+                    <a tal:replace="structure milestone_menu/create_release/fmt:icon-link" />
+                  </dd>
+                </dl>
+
+                <tal:has_release condition="view/release">
+                  <dl>
+                    <dt>Released:</dt>
+                    <dd><span
+                      tal:attributes="title view/release/datereleased/fmt:datetime"
+                      tal:content="view/release/datereleased/fmt:approximatedate" />
+                      &nbsp;
+                      <a tal:replace="structure release_menu/delete/fmt:icon" />
+                      <a tal:replace="structure release_menu/edit/fmt:icon" />
+                      </dd>
+                  </dl>
+
+                  <dl>
+                    <dt>Registrant:</dt>
+                    <dd><tal:registrant replace="structure view/release/owner/fmt:link"/></dd>
+                  </dl>
+
+                  <dl>
+                    <dt>Release registered:</dt>
+                    <dd><span
+                      tal:attributes="title view/release/datecreated/fmt:datetime"
+                      tal:content="view/release/datecreated/fmt:approximatedate" /></dd>
+                  </dl>
+                </tal:has_release>
+              </tal:is_not_project_milestone_tag>
             </div>
 
             <dl tal:condition="not: view/is_project_milestone">
@@ -310,7 +317,7 @@
               milestones for
               <tal:project replace="view/milestone/target/project/displayname" /></a>
           </li>
-          <li tal:condition="view/milestone/series_target">
+          <li tal:condition="view/milestone/series_target|nothing">
             <a class="sprite info"
               tal:attributes="href view/milestone/series_target/fmt:url">View
               releases for the

=== added file 'lib/lp/registry/templates/milestone-tag-search.pt'
--- lib/lp/registry/templates/milestone-tag-search.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/milestone-tag-search.pt	2012-01-05 15:15:17 +0000
@@ -0,0 +1,40 @@
+<tal:root
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  omit-tag="">
+
+  <form
+    action="."
+    tal:attributes="action view/action_url"
+    name="launchpadform"
+    method="get"
+    accept-charset="UTF-8">
+
+    <p class="error message"
+      tal:condition="view/errors"
+      tal:content="view/error_count" />
+
+    <p class="error message"
+      tal:repeat="form_wide_error view/form_wide_errors"
+      tal:content="structure form_wide_error">
+      Schema validation errors.
+    </p>
+
+    <p>
+      <tal:tags replace="structure view/widgets/tags" />
+      <input tal:replace="structure view/search_by_tags/render" />
+      <a target="help" href="/+help-registry/milestone-tags.html" class="sprite maybe">
+        &nbsp;<span class="invisible-link">Milestone Tags help</span>
+      </a>
+    </p>
+
+    <div class="error" tal:condition="view/errors">
+      <div class="message">Invalid tag name.</div>
+    </div>
+
+    <p class="formHelp" tal:content="view/widgets/tags/hint">Some Help Text</p>
+
+  </form>
+
+</tal:root>

=== modified file 'lib/lp/registry/templates/project-index.pt'
--- lib/lp/registry/templates/project-index.pt	2011-06-16 13:50:58 +0000
+++ lib/lp/registry/templates/project-index.pt	2012-01-05 15:15:17 +0000
@@ -109,6 +109,15 @@
         <tal:has-many-project condition="view/has_many_projects">
           <tal:sprints content="structure context/@@+portlet-coming-sprints" />
         </tal:has-many-project>
+
+        <h2>Milestones</h2>
+        <ul>
+          <li>
+            <a tal:replace="structure context/menu:overview/milestones/fmt:link" />
+          </li>
+        </ul>
+        <tal:search_by_tags_form
+          content="structure view/project_group_milestone_tag/@@+portlet-milestone-tag-search" />
       </div>
 
       <div class="yui-u" id="products">
@@ -128,9 +137,6 @@
             <li tal:condition="context/menu:overview/new_product/enabled">
               <a tal:replace="structure context/menu:overview/new_product/fmt:link" />
             </li>
-            <li>
-              <a tal:replace="structure context/menu:overview/milestones/fmt:link" />
-            </li>
           </ul>
         </div>
 

=== modified file 'lib/lp/registry/tests/test_milestone.py'
--- lib/lp/registry/tests/test_milestone.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/tests/test_milestone.py	2012-01-05 15:15:17 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Milestone related test helper."""
@@ -18,6 +18,7 @@
     )
 from lp.registry.interfaces.product import IProductSet
 from lp.testing import (
+    person_logged_in,
     ANONYMOUS,
     login,
     logout,
@@ -128,3 +129,64 @@
     def test_projectgroup(self):
         projectgroup = self.factory.makeProject()
         self.check_skipped(projectgroup)
+
+
+class MilestoneBugTaskTest(TestCaseWithFactory):
+    """Test cases for retrieving bugtasks for a milestone."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(MilestoneBugTaskTest, self).setUp()
+        self.owner = self.factory.makePerson()
+        self.product = self.factory.makeProduct(name="product1")
+        self.milestone = self.factory.makeMilestone(product=self.product)
+
+    def _create_bugtasks(self, num, milestone=None):
+        bugtasks = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                bugtask = self.factory.makeBugTask(
+                    target=self.product,
+                    owner=self.owner)
+                if milestone:
+                    bugtask.milestone = milestone
+                bugtasks.append(bugtask)
+        return bugtasks
+
+    def test_bugtask_retrieval(self):
+        # Ensure that all bugtasks on a milestone can be retrieved.
+        bugtasks = self._create_bugtasks(5, self.milestone)
+        self.assertContentEqual(
+            bugtasks,
+            self.milestone.bugtasks(self.owner))
+
+
+class MilestoneSpecificationTest(TestCaseWithFactory):
+    """Test cases for retrieving specifications for a milestone."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(MilestoneSpecificationTest, self).setUp()
+        self.owner = self.factory.makePerson()
+        self.product = self.factory.makeProduct(name="product1")
+        self.milestone = self.factory.makeMilestone(product=self.product)
+
+    def _create_specifications(self, num, milestone=None):
+        specifications = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                specification = self.factory.makeSpecification(
+                    product=self.product,
+                    owner=self.owner,
+                    milestone=milestone)
+                specifications.append(specification)
+        return specifications
+
+    def test_specification_retrieval(self):
+        # Ensure that all specifications on a milestone can be retrieved.
+        specifications = self._create_specifications(5, self.milestone)
+        self.assertContentEqual(
+            specifications,
+            self.milestone.specifications)

=== added file 'lib/lp/registry/tests/test_milestonetag.py'
--- lib/lp/registry/tests/test_milestonetag.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/tests/test_milestonetag.py	2012-01-05 15:15:17 +0000
@@ -0,0 +1,208 @@
+# Copyright 2011-2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Milestone related test helper."""
+
+__metaclass__ = type
+
+import transaction
+
+from lp.testing.layers import (
+    AppServerLayer,
+    DatabaseFunctionalLayer,
+    )
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    ws_object,
+    )
+
+
+class MilestoneTagTest(TestCaseWithFactory):
+    """Test cases for setting and retrieving milestone tags."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(MilestoneTagTest, self).setUp()
+        self.milestone = self.factory.makeMilestone()
+        self.person = self.milestone.target.owner
+        self.tags = [u'tag2', u'tag1', u'tag3']
+
+    def test_no_tags(self):
+        # Ensure a newly created milestone does not have associated tags.
+        self.assertEquals([], self.milestone.getTags())
+
+    def test_tags_setting_and_retrieval(self):
+        # Ensure tags are correctly saved and retrieved from the db.
+        with person_logged_in(self.person):
+            self.milestone.setTags(self.tags, self.person)
+        self.assertEqual(sorted(self.tags), self.milestone.getTags())
+
+    def test_tags_override(self):
+        # Ensure you can override tags already associated with the milestone.
+        with person_logged_in(self.person):
+            self.milestone.setTags(self.tags, self.person)
+            new_tags = [u'tag2', u'tag4', u'tag3']
+            self.milestone.setTags(new_tags, self.person)
+        self.assertEqual(sorted(new_tags), self.milestone.getTags())
+
+    def test_tags_deletion(self):
+        # Ensure passing an empty sequence of tags deletes them all.
+        with person_logged_in(self.person):
+            self.milestone.setTags(self.tags, self.person)
+            self.milestone.setTags([], self.person)
+        self.assertEquals([], self.milestone.getTags())
+
+
+class ProjectGroupMilestoneTagTest(TestCaseWithFactory):
+    """Test cases for retrieving bugtasks for a milestonetag."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(ProjectGroupMilestoneTagTest, self).setUp()
+        self.owner = self.factory.makePerson()
+        self.project_group = self.factory.makeProject(owner=self.owner)
+        self.product = self.factory.makeProduct(
+            name="product1",
+            owner=self.owner,
+            project=self.project_group)
+        self.milestone = self.factory.makeMilestone(product=self.product)
+
+    def _create_bugtasks(self, num, milestone=None):
+        bugtasks = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                bugtask = self.factory.makeBugTask(
+                    target=self.product,
+                    owner=self.owner)
+                if milestone:
+                    bugtask.milestone = milestone
+                bugtasks.append(bugtask)
+        return bugtasks
+
+    def _create_specifications(self, num, milestone=None):
+        specifications = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                specification = self.factory.makeSpecification(
+                    product=self.product,
+                    owner=self.owner,
+                    milestone=milestone)
+                specifications.append(specification)
+        return specifications
+
+    def _create_items_for_retrieval(self, factory, tag=u'tag1'):
+        with person_logged_in(self.owner):
+            self.milestone.setTags([tag], self.owner)
+            items = factory(5, self.milestone)
+            milestonetag = ProjectGroupMilestoneTag(
+                target=self.project_group, tags=[tag])
+        return items, milestonetag
+
+    def _create_items_for_untagged_milestone(self, factory, tag=u'tag1'):
+        new_milestone = self.factory.makeMilestone(product=self.product)
+        with person_logged_in(self.owner):
+            self.milestone.setTags([tag], self.owner)
+            items = factory(5, self.milestone)
+            factory(3, new_milestone)
+            milestonetag = ProjectGroupMilestoneTag(
+                target=self.project_group, tags=[tag])
+        return items, milestonetag
+
+    def _create_items_for_multiple_tags(
+        self, factory, tags=(u'tag1', u'tag2')):
+        new_milestone = self.factory.makeMilestone(product=self.product)
+        with person_logged_in(self.owner):
+            self.milestone.setTags(tags, self.owner)
+            new_milestone.setTags(tags[:1], self.owner)
+            items = factory(5, self.milestone)
+            factory(3, new_milestone)
+            milestonetag = ProjectGroupMilestoneTag(
+                target=self.project_group, tags=tags)
+        return items, milestonetag
+
+    # Add a test similar to TestProjectExcludeConjoinedMasterSearch in
+    # lp.bugs.tests.test_bugsearch_conjoined.
+
+    def test_bugtask_retrieve_single_milestone(self):
+        # Ensure that all bugtasks on a single milestone can be retrieved.
+        bugtasks, milestonetag = self._create_items_for_retrieval(
+            self._create_bugtasks)
+        self.assertContentEqual(bugtasks, milestonetag.bugtasks(self.owner))
+
+    def test_bugtasks_for_untagged_milestone(self):
+        # Ensure that bugtasks for a project group are retrieved
+        # only if associated with milestones having specified tags.
+        bugtasks, milestonetag = self._create_items_for_untagged_milestone(
+            self._create_bugtasks)
+        self.assertContentEqual(bugtasks, milestonetag.bugtasks(self.owner))
+
+    def test_bugtasks_multiple_tags(self):
+        # Ensure that, in presence of multiple tags, only bugtasks
+        # for milestones associated with all the tags are retrieved.
+        bugtasks, milestonetag = self._create_items_for_multiple_tags(
+            self._create_bugtasks)
+        self.assertContentEqual(bugtasks, milestonetag.bugtasks(self.owner))
+
+    def test_specification_retrieval(self):
+        # Ensure that all specifications on a milestone can be retrieved.
+        specs, milestonetag = self._create_items_for_retrieval(
+            self._create_specifications)
+        self.assertContentEqual(specs, milestonetag.specifications)
+
+    def test_specifications_for_untagged_milestone(self):
+        # Ensure that specifications for a project group are retrieved
+        # only if associated with milestones having specified tags.
+        specs, milestonetag = self._create_items_for_untagged_milestone(
+            self._create_specifications)
+        self.assertContentEqual(specs, milestonetag.specifications)
+
+    def test_specifications_multiple_tags(self):
+        # Ensure that, in presence of multiple tags, only specifications
+        # for milestones associated with all the tags are retrieved.
+        specs, milestonetag = self._create_items_for_multiple_tags(
+            self._create_specifications)
+        self.assertContentEqual(specs, milestonetag.specifications)
+
+
+class MilestoneTagWebserviceTest(TestCaseWithFactory):
+    """Test the getter and setter for milestonetags."""
+
+    layer = AppServerLayer
+
+    def setUp(self):
+        super(MilestoneTagWebserviceTest, self).setUp()
+        self.owner = self.factory.makePerson()
+        self.product = self.factory.makeProduct(owner=self.owner)
+        self.milestone = self.factory.makeMilestone(product=self.product)
+        transaction.commit()
+        self.service = self.factory.makeLaunchpadService(self.owner)
+        self.ws_milestone = ws_object(
+            self.service, self.milestone)
+
+    def test_get_tags_none(self):
+        self.assertEqual([], self.ws_milestone.getTags())
+
+    def test_get_tags(self):
+        tags = [u'zeta', u'alpha', u'beta']
+        self.milestone.setTags(tags, self.owner)
+        self.assertEqual(sorted(tags), self.ws_milestone.getTags())
+
+    def test_set_tags_initial(self):
+        tags = [u'zeta', u'alpha', u'beta']
+        self.ws_milestone.setTags(tags=tags)
+        self.ws_milestone.lp_save()
+        transaction.begin()
+        self.assertEqual(sorted(tags), self.milestone.getTags())
+
+    def test_set_tags_replace(self):
+        tags1 = [u'zeta', u'alpha', u'beta']
+        self.milestone.setTags(tags1, self.owner)
+        tags2 = [u'delta', u'alpha', u'gamma']
+        self.ws_milestone.setTags(tags=tags2)
+        self.ws_milestone.lp_save()
+        transaction.begin()
+        self.assertEqual(sorted(tags2), self.milestone.getTags())

=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py	2012-01-05 03:11:37 +0000
+++ lib/lp/registry/tests/test_person.py	2012-01-05 15:15:17 +0000
@@ -55,7 +55,7 @@
     IMasterStore,
     IStore,
     )
-from lp.services.database.sqlbase import cursor
+from lp.services.database.sqlbase import cursor, sqlvalues
 from lp.services.identity.interfaces.account import (
     AccountCreationRationale,
     AccountStatus,
@@ -847,8 +847,17 @@
         with celebrity_logged_in('admin'):
             email = from_person.preferredemail
             email.status = EmailAddressStatus.NEW
-            email.person = to_person
-            email.account = to_person.account
+            store = IMasterStore(EmailAddress)
+            # EmailAddress.acount and .person need to be updated at the
+            # same time to prevent the constraints on the account field
+            # from kicking the change out.
+            store.execute("""
+                UPDATE EmailAddress SET
+                    person = %s,
+                    account = %s
+                WHERE id = %s
+                """ % sqlvalues(
+                to_person.id, to_person.accountID, email.id))
         transaction.commit()
 
     def _do_merge(self, from_person, to_person, reviewer=None):

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2011-12-30 06:14:56 +0000
+++ lib/lp/security.py	2012-01-05 15:15:17 +0000
@@ -108,6 +108,7 @@
 from lp.registry.interfaces.location import IPersonLocation
 from lp.registry.interfaces.milestone import (
     IMilestone,
+    IAbstractMilestone,
     IProjectGroupMilestone,
     )
 from lp.registry.interfaces.nameblacklist import (
@@ -430,8 +431,8 @@
 
 
 class ViewMilestone(AnonymousAuthorization):
-    """Anyone can view an IMilestone."""
-    usedfor = IMilestone
+    """Anyone can view an IMilestone or an IProjectGroupMilestone."""
+    usedfor = IAbstractMilestone
 
 
 class EditSpecificationBranch(AuthorizationBase):

=== modified file 'lib/lp/services/mail/tests/emails/x-unknown-encoding.txt'
--- lib/lp/services/mail/tests/emails/x-unknown-encoding.txt	2009-07-03 09:20:34 +0000
+++ lib/lp/services/mail/tests/emails/x-unknown-encoding.txt	2012-01-05 15:15:17 +0000
@@ -1,7 +1,7 @@
 MIME-Version: 1.0
 Date: Wed, 13 Apr 2005 19:20:48 -0400
 Message-ID: <bbe079320907030136g23826072s7a653e75f8ecdd58@xxxxxxxxxxxxxxxx>
-Subject: Uknown Encoding
+Subject: Unknown Encoding
 From: test@xxxxxxxxxxxxx
 To: someone@xxxxxxxxxxx
 X-Original-To: someone@xxxxxxxxxxx
@@ -9,4 +9,3 @@
 Content-Transfer-Encoding: QUOTED-PRINTABLE
 
 Mysterious, intriguing, the encoding is unknown.
-


Follow ups