← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~bac/launchpad/904335-milestone-edit into lp:launchpad

 

Brad Crittenden has proposed merging lp:~bac/launchpad/904335-milestone-edit into lp:launchpad.

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/904335-milestone-edit/+merge/87794

Add the tags field to the milestone +edit page.

If there is a cleaner way to insert the tags field into the form I'm
open for suggestions. 

Tests:
bin/test -vv lp.registry.browser.tests.test_milestone TestMilestoneEditView

No lint.
-- 
https://code.launchpad.net/~bac/launchpad/904335-milestone-edit/+merge/87794
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~bac/launchpad/904335-milestone-edit 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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-05 21:38:46 +0000
+++ database/schema/security.cfg	2012-01-06 18:30:42 +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
@@ -592,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
@@ -650,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
@@ -863,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
@@ -1302,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
@@ -1405,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
@@ -1503,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
@@ -1707,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
@@ -1947,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
@@ -2045,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
@@ -2148,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	2012-01-05 18:12:05 +0000
+++ lib/lp/bugs/browser/bugtask.py	2012-01-06 18:30:42 +0000
@@ -3504,7 +3504,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-06 18:30:42 +0000
@@ -918,8 +918,7 @@
         """
 
     def userHasBugSupervisorPrivileges(user):
-        """Is the user a privledged one, allowed to changed details on a
-        bug?
+        """Is the user privileged and allowed to change details on a bug?
 
         :return: A boolean.
         """
@@ -1193,9 +1192,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 +1217,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/javascript/bug_tags_entry.js'
--- lib/lp/bugs/javascript/bug_tags_entry.js	2011-08-09 14:18:02 +0000
+++ lib/lp/bugs/javascript/bug_tags_entry.js	2012-01-06 18:30:42 +0000
@@ -46,9 +46,9 @@
     tag_list_span.all(A).each(function(anchor) {
         tags.push(anchor.get(INNER_HTML));
     });
-    
+
     var tag_list = tags.join(' ');
-    /* If there are tags then add a space to the end of the string so the user 
+    /* If there are tags then add a space to the end of the string so the user
        doesn't have to type one. */
     if (tag_list != "") {
         tag_list += ' ';
@@ -61,6 +61,13 @@
  */
 var base_url = window.location.href.split('/+bug')[0] + '/+bugs?field.tag=';
 
+namespace.parse_tags = function(tag_string) {
+    var tags  = Y.Array(
+        Y.Lang.trim(tag_string).split(new RegExp('\\s+'))).filter(
+            function(elem) { return elem !== ''; });
+    return tags;
+};
+
 /**
  * Save the currently entered tags and switch inline editing off.
  *
@@ -68,9 +75,7 @@
  */
 var save_tags = function() {
     var lp_client = new Y.lp.client.Launchpad();
-    var tags = Y.Array(
-        Y.Lang.trim(tag_input.get(VALUE)).split(new RegExp('\\s+'))).filter(
-            function(elem) { return elem !== ''; });
+    var tags = namespace.parse_tags(tag_input.get(VALUE));
     var bug = new Y.lp.client.Entry(
         lp_client, LP.cache[BUG], LP.cache[BUG].self_link);
     bug.removeAttr('http_etag');
@@ -243,4 +248,3 @@
 }, "0.1", {"requires": ["base", "io-base", "node", "substitute", "node-menunav",
                         "lazr.base", "lp.anim", "lazr.autocomplete",
                         "lp.client"]});
-

=== added file 'lib/lp/bugs/javascript/tests/test_bug_tags_entry.html'
--- lib/lp/bugs/javascript/tests/test_bug_tags_entry.html	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_tags_entry.html	2012-01-06 18:30:42 +0000
@@ -0,0 +1,25 @@
+<html>
+  <head>
+    <title>Launchpad bug tags entry</title>
+    <!-- YUI and test setup -->
+    <script type="text/javascript"
+          src="../../../../canonical/launchpad/icing/yui/yui/yui.js">
+    </script>
+    <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
+    <script type="text/javascript"
+          src="../../../app/javascript/testing/testrunner.js"></script>
+
+    <!-- Other dependencies -->
+    <script type="text/javascript" src="../../../app/javascript/lp.js"></script>
+    <script type="text/javascript" src="../../../app/javascript/client.js"></script>
+    <script type="text/javascript" src="../../../app/javascript/testing/mockio.js"></script>
+
+    <!-- The module under test -->
+    <script type="text/javascript" src="../bug_tags_entry.js"></script>
+
+    <!-- The test suite -->
+    <script type="text/javascript" src="test_bug_tags_entry.js"></script>
+  </head>
+  <body class="yui3-skin-sam">
+  </body>
+</html>

=== added file 'lib/lp/bugs/javascript/tests/test_bug_tags_entry.js'
--- lib/lp/bugs/javascript/tests/test_bug_tags_entry.js	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_tags_entry.js	2012-01-06 18:30:42 +0000
@@ -0,0 +1,44 @@
+YUI().use('lp.testing.runner', 'lp.testing.mockio', 'test', 'console',
+          'lp.client', 'node-event-simulate', 'lp.bugs.bug_tags_entry',
+    function(Y) {
+
+    var module = Y.lp.bugs.bug_tags_entry;
+    var suite = new Y.Test.Suite("Bug tags entry Tests");
+
+    suite.add(new Y.Test.Case({
+        name: 'Tags parsing',
+
+        test_empty_string: function() {
+            var tag_string = '';
+            var results = module.parse_tags(tag_string);
+            Y.ArrayAssert.itemsAreEqual([], results);
+        },
+
+        test_one_item: function() {
+            var tag_string = 'cow';
+            var results = module.parse_tags(tag_string);
+            Y.ArrayAssert.itemsAreEqual(['cow'], results);
+        },
+
+        test_two_items: function() {
+            var tag_string = 'cow pig';
+            var results = module.parse_tags(tag_string);
+            Y.ArrayAssert.itemsAreEqual(['cow', 'pig'], results);
+        },
+
+        test_spaces: function() {
+            var tag_string = '   ';
+            var results = module.parse_tags(tag_string);
+            Y.ArrayAssert.itemsAreEqual([], results);
+        },
+
+        test_items_with_spaces: function() {
+            var tag_string = ' cow pig  chicken  ';
+            var results = module.parse_tags(tag_string);
+            Y.ArrayAssert.itemsAreEqual(['cow', 'pig', 'chicken'], results);
+        }
+
+      }));
+
+    Y.lp.testing.Runner.run(suite);
+});

=== 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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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,10 @@
     implements,
     Interface,
     )
-from zope.schema import Choice
+from zope.schema import (
+    Choice,
+    TextLine,
+    )
 
 from lp import _
 from lp.app.browser.launchpadform import (
@@ -35,7 +42,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 +61,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 +97,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 +152,7 @@
 
 class MilestoneContextMenu(ContextMenu, MilestoneLinkMixin):
     """The menu for this milestone."""
-    usedfor = IMilestone
+    usedfor = IMilestoneData
 
     @cachedproperty
     def links(self):
@@ -150,8 +163,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 +178,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 +194,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 +215,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 +323,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 +414,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."""
@@ -403,14 +431,35 @@
 
     custom_widget('dateexpected', DateWidget)
 
+    def extendFields(self):
+        """See `LaunchpadFormView`.
+
+        Add a text-entry widget for milestone tags since there is not property
+        on the interface.
+        """
+        tag_entry = TextLine(
+            __name__='tags',
+            title=u'Tags',
+            required=False)
+        self.form_fields += form.Fields(
+            tag_entry, render_context=self.render_context)
+        # Make an instance attribute to avoid mutating the class attribute.
+        self.field_names = self.field_names[:]
+        # Insert the tags field before the summary.
+        summary_index = self.field_names.index('summary')
+        self.field_names[summary_index:summary_index] = [tag_entry.__name__]
+
     @action(_('Register Milestone'), name='register')
     def register_action(self, action, data):
         """Use the newMilestone method on the context to make a milestone."""
-        self.context.newMilestone(
+        milestone = self.context.newMilestone(
             name=data.get('name'),
             code_name=data.get('code_name'),
             dateexpected=data.get('dateexpected'),
             summary=data.get('summary'))
+        tags = data.get('tags')
+        if tags:
+            milestone.setTags(tags.lower().split(), self.user)
         self.next_url = canonical_url(self.context)
 
     @property
@@ -443,7 +492,7 @@
         return canonical_url(self.context)
 
     @property
-    def field_names(self):
+    def _field_names(self):
         """See `LaunchpadFormView`.
 
         There are two series fields, one for for product milestones and the
@@ -459,6 +508,31 @@
             names.append('productseries')
         return names
 
+    @property
+    def initial_values(self):
+        tags = self.context.getTags()
+        tagstring = ' '.join(tags)
+        return dict(tags=tagstring)
+
+    def extendFields(self):
+        """See `LaunchpadFormView`.
+
+        Add a text-entry widget for milestone tags since there is not property
+        on the interface.
+        """
+        tag_entry = TextLine(
+            __name__='tags',
+            title=u'Tags',
+            required=False)
+        self.form_fields += form.Fields(
+            tag_entry, render_context=self.render_context)
+
+        # Make an instance attribute to avoid mutating the class attribute.
+        self.field_names = self._field_names[:]
+        # Insert the tags field before the summary.
+        summary_index = self.field_names.index('summary')
+        self.field_names[summary_index:summary_index] = [tag_entry.__name__]
+
     def setUpFields(self):
         """See `LaunchpadFormView`.
 
@@ -483,7 +557,9 @@
     @action(_('Update'), name='update')
     def update_action(self, action, data):
         """Update the milestone."""
+        tags = data.pop('tags') or u''
         self.updateContextFromData(data)
+        self.context.setTags(tags.lower().split(), self.user)
         self.next_url = canonical_url(self.context)
 
 
@@ -533,6 +609,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-06 18:30:42 +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/milestone-views.txt'
--- lib/lp/registry/browser/tests/milestone-views.txt	2011-12-30 08:13:14 +0000
+++ lib/lp/registry/browser/tests/milestone-views.txt	2012-01-06 18:30:42 +0000
@@ -508,12 +508,12 @@
 
     >>> owner = firefox.owner
     >>> login_person(owner)
-    >>> view = create_view(firefox_1_0, '+addmilestone')
+    >>> view = create_initialized_view(firefox_1_0, '+addmilestone')
     >>> print view.label
     Register a new milestone
 
     >>> view.field_names
-    ['name', 'code_name', 'dateexpected', 'summary']
+    ['name', 'code_name', 'dateexpected', 'tags', 'summary']
 
 The view provides an action_url and cancel_url properties that form
 submitting the form or aborting the action.

=== 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-06 18:30:42 +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 (
@@ -44,8 +45,8 @@
         self.product = self.factory.makeProduct()
         self.series = (
             self.factory.makeProductSeries(product=self.product))
-        owner = self.product.owner
-        login_person(owner)
+        self.owner = self.product.owner
+        login_person(self.owner)
 
     def test_add_milestone(self):
         form = {
@@ -64,7 +65,7 @@
             }
         view = create_initialized_view(
             self.series, '+addmilestone', form=form)
-        # It's important to make sure no errors occured, but
+        # It's important to make sure no errors occured,
         # but also confirm that the milestone was created.
         self.assertEqual([], view.errors)
         self.assertEqual('1.1', self.product.milestones[0].name)
@@ -83,6 +84,63 @@
             "like YYYY-MM-DD format. The year must be after 1900.")
         self.assertEqual(expected_msg, error_msg)
 
+    def test_add_milestone_with_tags(self):
+        tags = u'zed alpha'
+        form = {
+            'field.name': '1.1',
+            'field.tags': tags,
+            'field.actions.register': 'Register Milestone',
+            }
+        view = create_initialized_view(
+            self.series, '+addmilestone', form=form)
+        self.assertEqual([], view.errors)
+        expected = sorted(tags.split())
+        self.assertEqual(expected, list(self.product.milestones[0].getTags()))
+
+
+class TestMilestoneEditView(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        TestCaseWithFactory.setUp(self)
+        self.product = self.factory.makeProduct()
+        self.milestone = self.factory.makeMilestone(
+            name='orig-name', product=self.product)
+        self.owner = self.product.owner
+        login_person(self.owner)
+
+    def test_edit_milestone_with_tags(self):
+        orig_tags = u'b a c'
+        self.milestone.setTags(orig_tags.split(), self.owner)
+        new_tags = u'z a B'
+        form = {
+            'field.name': 'new-name',
+            'field.tags': new_tags,
+            'field.actions.update': 'Update',
+            }
+        view = create_initialized_view(
+            self.milestone, '+edit', form=form)
+        self.assertEqual([], view.errors)
+        self.assertEqual('new-name', self.milestone.name)
+        expected = sorted(new_tags.lower().split())
+        self.assertEqual(expected, list(self.milestone.getTags()))
+
+    def test_edit_milestone_clear_tags(self):
+        orig_tags = u'b a c'
+        self.milestone.setTags(orig_tags.split(), self.owner)
+        form = {
+            'field.name': 'new-name',
+            'field.tags': '',
+            'field.actions.update': 'Update',
+            }
+        view = create_initialized_view(
+            self.milestone, '+edit', form=form)
+        self.assertEqual([], view.errors)
+        self.assertEqual('new-name', self.milestone.name)
+        expected = []
+        self.assertEqual(expected, list(self.milestone.getTags()))
+
 
 class TestMilestoneMemcache(MemcacheTestCase):
 
@@ -430,3 +488,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_bugtask_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-06 18:30:42 +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-06 18:30:42 +0000
@@ -0,0 +1,20 @@
+<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>
+      Milestone tags are a way to group milestones within a project group.
+      You can filter based on one or more tags to see the bugs and blueprints
+      assigned to all milestones that have the given tags.
+    </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-06 18:30:42 +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,34 @@
         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 +290,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-06 18:30:42 +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.
+    """

=== added file 'lib/lp/registry/javascript/__init__.py'
--- lib/lp/registry/javascript/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/__init__.py	2012-01-06 18:30:42 +0000
@@ -0,0 +1,1 @@
+"""Tests for lp.registry.javascript.tests module."""

=== modified file 'lib/lp/registry/javascript/milestoneoverlay.js'
--- lib/lp/registry/javascript/milestoneoverlay.js	2011-08-09 14:18:02 +0000
+++ lib/lp/registry/javascript/milestoneoverlay.js	2012-01-06 18:30:42 +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).
  *
  * A milestone form overlay that can create a milestone within any page.
@@ -14,6 +14,7 @@
     var milestone_form_uri;
     var series_uri;
     var next_step;
+    var client_sync = false;
 
     var save_new_milestone = function(data) {
 
@@ -33,14 +34,43 @@
             milestone_form.hide();
             // Reset the HTML form inside the widget.
             milestone_form.get('contentBox').one('form').reset();
-            next_step(parameters);
-        };
-
-        var client = new Y.lp.client.Launchpad();
+            if (Y.Lang.isValue(next_step)) {
+                next_step(parameters);
+            }
+        };
+
+        var set_milestone_tags = function(milestone) {
+            var tagstring = data['field.tags'][0].toLowerCase();
+            var tags = Y.lp.bugs.bug_tags_entry.parse_tags(tagstring);
+
+            if (tags.length === 0) {
+                return finish_new_milestone();
+            }
+
+            var parameters = {
+                tags: tags
+            };
+            milestone.named_post('setTags', {
+                parameters: parameters,
+                on: {
+                    success: finish_new_milestone,
+                    failure: function (ignore, response, args) {
+                        var error_box = Y.one('#milestone-error');
+                        var error_message = '<strong>' + response.statusText +
+                                            '</strong><p>' +
+                                            response.responseText +
+                                            '</p>';
+                        milestone_form.showError(error_message);
+                    }
+                }
+            });
+        };
+
+        var client = new Y.lp.client.Launchpad({sync: client_sync});
         client.named_post(series_uri, 'newMilestone', {
             parameters: parameters,
             on: {
-                success: finish_new_milestone,
+                success: set_milestone_tags,
                 failure: function (ignore, response, args) {
                     var error_box = Y.one('#milestone-error');
                     var error_message = '<strong>' + response.statusText +
@@ -52,7 +82,7 @@
             }
         });
     };
-
+    module.save_new_milestone = save_new_milestone;
 
     var setup_milestone_form = function () {
         var form_submit_button = Y.Node.create(
@@ -70,6 +100,7 @@
         Y.lp.app.calendar.add_calendar_widgets();
         milestone_form.show();
     };
+    module.setup_milestone_form = setup_milestone_form;
 
     var show_milestone_form = function(e) {
         e.preventDefault();
@@ -82,6 +113,29 @@
         }
     };
 
+    var configure = function(config) {
+        if (config === undefined) {
+            throw new Error(
+                "Missing attach_widget config for milestoneoverlay.");
+        }
+        if (config.milestone_form_uri === undefined ||
+            config.series_uri === undefined) {
+            throw new Error(
+                "attach_widget config for milestoneoverlay has " +
+                "undefined properties.");
+        }
+        milestone_form_uri = config.milestone_form_uri;
+        series_uri = config.series_uri;
+        // For testing purposes next_step may be undefined.
+        if (Y.Lang.isValue(config.next_step)) {
+            next_step = config.next_step;
+        }
+        if (Y.Lang.isValue(config.sync)) {
+            client_sync = config.sync;
+        }
+    };
+    module.configure = configure;
+
     /**
       * Attaches a milestone form overlay widget to an element.
       *
@@ -100,20 +154,7 @@
         if (Y.UA.ie) {
             return;
         }
-        if (config === undefined) {
-            throw new Error(
-                "Missing attach_widget config for milestoneoverlay.");
-        }
-        if (config.milestone_form_uri === undefined ||
-            config.series_uri === undefined ||
-            config.next_step === undefined) {
-            throw new Error(
-                "attach_widget config for milestoneoverlay has " +
-                "undefined properties.");
-        }
-        milestone_form_uri = config.milestone_form_uri;
-        series_uri = config.series_uri;
-        next_step = config.next_step;
+        configure(config);
         config.activate_node.on('click', show_milestone_form);
     };
 
@@ -123,5 +164,6 @@
                         "lp.anim",
                         "lazr.formoverlay",
                         "lp.app.calendar",
-                        "lp.client"
+                        "lp.client",
+                        "lp.bugs.bug_tags_entry"
                         ]});

=== added file 'lib/lp/registry/javascript/tests/__init__.py'
--- lib/lp/registry/javascript/tests/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/__init__.py	2012-01-06 18:30:42 +0000
@@ -0,0 +1,1 @@
+"""Tests for lp.registry.javascript.tests module."""

=== added file 'lib/lp/registry/javascript/tests/test_milestone_creation.js'
--- lib/lp/registry/javascript/tests/test_milestone_creation.js	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/test_milestone_creation.js	2012-01-06 18:30:42 +0000
@@ -0,0 +1,172 @@
+YUI({
+    base: '/+icing/yui/',
+    filter: 'raw', combine: false, fetchCSS: false
+}).use('test',
+       'lp.client',
+       'lp.testing.serverfixture',
+       'lp.registry.milestonetable',
+       'lp.registry.milestoneoverlay',
+       function(Y) {
+
+/**
+ * Integration tests for milestoneoverlay.
+ */
+var suite = new Y.Test.Suite("lp.registry.javascript.milestoneoverlay Tests");
+var serverfixture = Y.lp.testing.serverfixture;
+// Define the module-under-test.
+var the_module = Y.lp.registry.milestoneoverlay;
+
+var makeTestConfig = function(config) {
+  if (Y.Lang.isUndefined(config)) {
+    config = {};
+  }
+  config.on = Y.merge(
+    {
+      success: function(result) {
+          config.successful = true;
+          config.result = result;
+      },
+      failure: function(tid, response, args) {
+          config.successful = false;
+          config.result = {tid: tid, response: response, args: args};
+      }
+    },
+    config.on);
+  return config;
+};
+
+/**
+ * Test milestoneoverlay interaction via the API, such as creating
+ * milestones and adding tags.
+ */
+suite.add(new Y.Test.Case({
+    name: 'Milestone creation tests',
+
+    tearDown: function() {
+        // Always do this.
+        serverfixture.teardown(this);
+    },
+
+    test_configure: function() {
+        // Ensure configuring the module works as it will be needed in
+        // subsequent tests.
+        var config = {
+            milestone_form_uri: 'a',
+            series_uri: 'b',
+            next_step: 'c'
+        };
+        the_module.configure(config);
+    },
+
+    test_milestone_test_fixture_setup: function() {
+        // Setup the fixture, retrieving the objects we need for the test.
+        var data = serverfixture.setup(this, 'setup');
+        var client = new Y.lp.client.Launchpad({sync: true});
+        var config = makeTestConfig();
+        var product = new Y.lp.client.Entry(
+            client, data.product, data.product.self_link);
+        var product_name = product.get('name');
+        Y.Assert.areEqual('my-test-project', product_name);
+    },
+
+    test_milestone_creation_no_tags: function() {
+        // Setup the fixture, retrieving the objects we need for the test.
+        var data = serverfixture.setup(this, 'setup');
+
+        // Initialize the milestoneoverlay module.
+        var milestone_table = Y.lp.registry.milestonetable;
+        var config = {
+            milestone_form_uri: data.milestone_form_uri,
+            series_uri: data.series_uri,
+            //next_step: milestone_table.get_milestone_row,
+            sync: true
+        };
+        the_module.configure(config);
+        the_module.setup_milestone_form();
+
+        var milestone_name = 'new-milestone';
+        var code_name = 'new-codename';
+        var params = {
+            'field.name': [milestone_name],
+            'field.code_name': [code_name],
+            'field.dateexpected': [''],
+            'field.tags': [''],
+            'field.summary': ['']
+        };
+
+        // Test the creation of the new milestone.
+        the_module.save_new_milestone(params);
+
+        // Verify the milestone was created.
+        var client = new Y.lp.client.Launchpad({sync: true});
+        var product = new Y.lp.client.Entry(
+            client, data.product, data.product.self_link);
+        config = makeTestConfig({parameters: {name: milestone_name}});
+        // Get the new milestone.
+        product.named_get('getMilestone', config);
+        Y.Assert.isTrue(config.successful, 'Getting milestone failed');
+        var milestone = config.result;
+        Y.Assert.isInstanceOf(Y.lp.client.Entry, milestone);
+        Y.Assert.areEqual(milestone_name, milestone.get('name'));
+        Y.Assert.areEqual(code_name, milestone.get('code_name'));
+        // Ensure no tags are created.
+        config = makeTestConfig({parameters: {}});
+        milestone.named_get('getTags', config);
+        Y.Assert.isTrue(config.successful, 'call to getTags failed');
+        var expected = [];
+        Y.ArrayAssert.itemsAreEqual(expected, config.result);
+    },
+
+    test_milestone_creation_with_tags: function() {
+        // Setup the fixture, retrieving the objects we need for the test.
+        var data = serverfixture.setup(this, 'setup');
+
+        // Initialize the milestoneoverlay module.
+        var milestone_table = Y.lp.registry.milestonetable;
+        var config = {
+            milestone_form_uri: data.milestone_form_uri,
+            series_uri: data.series_uri,
+            //next_step: milestone_table.get_milestone_row,
+            sync: true
+        };
+        the_module.configure(config);
+        the_module.setup_milestone_form();
+
+        var milestone_name = 'new-milestone';
+        var code_name = 'new-codename';
+        var tags = ['zeta  alpha beta'];
+        var params = {
+            'field.name': [milestone_name],
+            'field.code_name': [code_name],
+            'field.dateexpected': [''],
+            'field.tags': tags,
+            'field.summary': ['']
+        };
+
+        // Test the creation of the new milestone.
+        the_module.save_new_milestone(params);
+
+        // Verify the milestone was created.
+        var client = new Y.lp.client.Launchpad({sync: true});
+        var product = new Y.lp.client.Entry(
+            client, data.product, data.product.self_link);
+        config = makeTestConfig({parameters: {name: milestone_name}});
+        // Get the new milestone.
+        product.named_get('getMilestone', config);
+        Y.Assert.isTrue(config.successful, 'Getting milestone failed');
+        var milestone = config.result;
+        Y.Assert.isInstanceOf(Y.lp.client.Entry, milestone);
+        Y.Assert.areEqual(milestone_name, milestone.get('name'));
+        Y.Assert.areEqual(code_name, milestone.get('code_name'));
+        // Ensure the tags are created.
+        config = makeTestConfig({parameters: {}});
+        milestone.named_get('getTags', config);
+        Y.Assert.isTrue(config.successful, 'call to getTags failed');
+        var expected = ["alpha", "beta", "zeta"];
+        Y.ArrayAssert.itemsAreEqual(expected, config.result);
+    }
+}));
+
+// The last line is necessary.  Include it.
+serverfixture.run(suite);
+});

=== added file 'lib/lp/registry/javascript/tests/test_milestone_creation.py'
--- lib/lp/registry/javascript/tests/test_milestone_creation.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/test_milestone_creation.py	2012-01-06 18:30:42 +0000
@@ -0,0 +1,39 @@
+# Copyright 2011-2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Support for the lp.registry.javascript.milestoneoverlay YUIXHR tests.
+"""
+
+__metaclass__ = type
+__all__ = []
+
+from lp.services.webapp.publisher import canonical_url
+from lp.testing import person_logged_in
+from lp.testing.factory import LaunchpadObjectFactory
+from lp.testing.yuixhr import (
+    login_as_person,
+    make_suite,
+    setup,
+    )
+
+
+factory = LaunchpadObjectFactory()
+
+
+@setup
+def setup(request, data):
+    owner = factory.makePerson()
+    with person_logged_in(owner):
+        product = factory.makeProduct(name="my-test-project", owner=owner)
+        product_series = factory.makeProductSeries(
+            name="new-series", product=product)
+        data['product'] = product
+        data['series_uri'] = canonical_url(
+            product_series, path_only_if_possible=True)
+        data['milestone_form_uri'] = (
+            canonical_url(product_series) + '/+addmilestone/++form++')
+    login_as_person(owner)
+
+
+def test_suite():
+    return make_suite(__name__)

=== modified file 'lib/lp/registry/model/distroseries.py'
--- lib/lp/registry/model/distroseries.py	2012-01-03 05:05:39 +0000
+++ lib/lp/registry/model/distroseries.py	2012-01-06 18:30:42 +0000
@@ -1408,12 +1408,15 @@
         return distroarchseries
 
     def newMilestone(self, name, dateexpected=None, summary=None,
-                     code_name=None):
+                     code_name=None, tags=None):
         """See `IDistroSeries`."""
-        return Milestone(
+        milestone = Milestone(
             name=name, code_name=code_name,
             dateexpected=dateexpected, summary=summary,
             distribution=self.distribution, distroseries=self)
+        if tags:
+            milestone.setTags(tags.split())
+        return milestone
 
     def getLatestUploads(self):
         """See `IDistroSeries`."""

=== 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-06 18:30:42 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2012 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-06 18:30:42 +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/product.py'
--- lib/lp/registry/model/product.py	2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/product.py	2012-01-06 18:30:42 +0000
@@ -926,10 +926,11 @@
 
     def getMilestone(self, name):
         """See `IProduct`."""
-        return Milestone.selectOne("""
+        results = Milestone.selectOne("""
             product = %s AND
             name = %s
             """ % sqlvalues(self.id, name))
+        return results
 
     def createBug(self, bug_params):
         """See `IBugTarget`."""

=== modified file 'lib/lp/registry/model/productseries.py'
--- lib/lp/registry/model/productseries.py	2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/productseries.py	2012-01-06 18:30:42 +0000
@@ -541,11 +541,14 @@
         return history
 
     def newMilestone(self, name, dateexpected=None, summary=None,
-                     code_name=None):
+                     code_name=None, tags=None):
         """See IProductSeries."""
-        return Milestone(
+        milestone = Milestone(
             name=name, dateexpected=dateexpected, summary=summary,
             product=self.product, productseries=self, code_name=code_name)
+        if tags:
+            milestone.setTags(tags.split())
+        return milestone
 
     def getTemplatesCollection(self):
         """See `IHasTranslationTemplates`."""

=== 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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +0000
@@ -107,6 +107,7 @@
 from lp.registry.interfaces.irc import IIrcID
 from lp.registry.interfaces.location import IPersonLocation
 from lp.registry.interfaces.milestone import (
+    IAbstractMilestone,
     IMilestone,
     IProjectGroupMilestone,
     )
@@ -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-06 18:30:42 +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.
-