← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/db-add-subscription-link into lp:launchpad/db-devel

 

Gary Poster has proposed merging lp:~danilo/launchpad/db-add-subscription-link into lp:launchpad/db-devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~danilo/launchpad/db-add-subscription-link/+merge/56622

This branch merges in some conflicting devel branches into db-devel, and resolves the conflicts.
-- 
https://code.launchpad.net/~danilo/launchpad/db-add-subscription-link/+merge/56622
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/db-add-subscription-link into lp:launchpad/db-devel.
=== modified file 'lib/lp/bugs/browser/structuralsubscription.py'
--- lib/lp/bugs/browser/structuralsubscription.py	2011-04-06 00:14:46 +0000
+++ lib/lp/bugs/browser/structuralsubscription.py	2011-04-06 17:31:46 +0000
@@ -33,6 +33,7 @@
 from zope.traversing.browser import absoluteURL
 
 from canonical.launchpad.webapp.authorization import check_permission
+from canonical.launchpad.webapp.interfaces import NoCanonicalUrl
 from canonical.launchpad.webapp.menu import (
     enabled_with_permission,
     Link,
@@ -390,10 +391,13 @@
     expose_user_administered_teams_to_js(request, user, context)
     expose_enum_to_js(request, BugTaskImportance, 'importances')
     expose_enum_to_js(request, BugTaskStatus, 'statuses')
-    if subscriptions is None:
+    if subscriptions is None or len(list(subscriptions)) == 0:
         subscriptions = []
+        target = context
+    else:
+        target = None
     expose_user_subscriptions_to_js(
-        user, subscriptions, request)
+        user, subscriptions, request, target)
 
 
 def expose_enum_to_js(request, enum, name):
@@ -421,11 +425,12 @@
             info.append({
                 'link': absoluteURL(team, api_request),
                 'title': team.title,
+                'url': canonical_url(team),
             })
     IJSONRequestCache(request).objects['administratedTeams'] = info
 
 
-def expose_user_subscriptions_to_js(user, subscriptions, request):
+def expose_user_subscriptions_to_js(user, subscriptions, request, target=None):
     """Make the user's subscriptions available to JavaScript."""
     info = {}
     api_request = IWebServiceClientRequest(request)
@@ -433,6 +438,19 @@
         administered_teams = []
     else:
         administered_teams = user.getAdministratedTeams()
+
+    if target is not None:
+        try:
+            # No subscriptions, which means we are on a target
+            # subscriptions page. Let's at least provide target details.
+            target_info = {}
+            target_info['title'] = target.title
+            target_info['url'] = canonical_url(target, rootsite='mainsite')
+            IJSONRequestCache(request).objects['target_info'] = target_info
+        except NoCanonicalUrl:
+            # We export nothing if the target implements no canonical URL.
+            pass
+
     for subscription in subscriptions:
         target = subscription.target
         record = info.get(target)

=== modified file 'lib/lp/bugs/browser/tests/test_expose.py'
--- lib/lp/bugs/browser/tests/test_expose.py	2011-04-05 18:29:45 +0000
+++ lib/lp/bugs/browser/tests/test_expose.py	2011-04-06 17:31:46 +0000
@@ -132,7 +132,7 @@
         self.assertThat(len(team_info), Equals(expected_number_teams))
         # The items info consist of a dictionary with link and title keys.
         for i in range(expected_number_teams):
-            self.assertThat(team_info[i], KeysEqual('link', 'title'))
+            self.assertThat(team_info[i], KeysEqual('link', 'title', 'url'))
         # The link is the title of the team.
         self.assertThat(
             team_info[0]['title'], Equals(u'Bug Supervisor Sub Team'))
@@ -165,7 +165,7 @@
         self.assertThat(len(team_info), Equals(expected_number_teams))
         # The items info consist of a dictionary with link and title keys.
         for i in range(expected_number_teams):
-            self.assertThat(team_info[i], KeysEqual('link', 'title'))
+            self.assertThat(team_info[i], KeysEqual('link', 'title', 'url'))
         # The link is the title of the team.
         self.assertThat(
             team_info[0]['title'], Equals(u'Bug Supervisor Sub Team'))
@@ -193,7 +193,7 @@
         self.assertThat(len(team_info), Equals(expected_number_teams))
         # The items info consist of a dictionary with link and title keys.
         for i in range(expected_number_teams):
-            self.assertThat(team_info[i], KeysEqual('link', 'title'))
+            self.assertThat(team_info[i], KeysEqual('link', 'title', 'url'))
         # The link is the title of the team.
         self.assertThat(
             team_info[0]['title'], Equals(u'Bug Supervisor Sub Team'))

=== modified file 'lib/lp/bugs/templates/bugtarget-subscription-list.pt'
--- lib/lp/bugs/templates/bugtarget-subscription-list.pt	2011-03-29 22:34:04 +0000
+++ lib/lp/bugs/templates/bugtarget-subscription-list.pt	2011-04-06 17:31:46 +0000
@@ -17,9 +17,13 @@
           request/features/malone.advanced-structural-subscriptions.enabled">
       LPS.use('lp.registry.structural_subscription', function(Y) {
           var module = Y.lp.registry.structural_subscription;
+          var config = {
+              content_box: "#structural-subscription-content-box",
+              add_filter_description: true};
           Y.on('domready', function() {
-              module.setup_bug_subscriptions(
-                  {content_box: "#structural-subscription-content-box"})
+              module.setup_bug_subscriptions(config);
+              module.setup_subscription_link(
+                  config, '#create-new-subscription');
           });
       });
     </script>
@@ -31,6 +35,12 @@
 
     <div id="maincontent">
       <div id="nonportlets" class="readable">
+        <div tal:condition="
+            features/malone.advanced-structural-subscriptions.enabled">
+          <a class="sprite add" id="create-new-subscription"
+             href="#">Add a subscription</a>
+        </div>
+
         <div id="subscription-listing"></div>
 
             <div id="structural-subscription-content-box"></div>

=== modified file 'lib/lp/code/interfaces/codehosting.py'
--- lib/lp/code/interfaces/codehosting.py	2011-02-24 15:30:54 +0000
+++ lib/lp/code/interfaces/codehosting.py	2011-04-06 17:31:46 +0000
@@ -8,6 +8,7 @@
 __metaclass__ = type
 __all__ = [
     'BRANCH_ALIAS_PREFIX',
+    'BRANCH_ID_ALIAS_PREFIX',
     'BRANCH_TRANSPORT',
     'compose_public_url',
     'CONTROL_TRANSPORT',
@@ -55,6 +56,9 @@
 
 # The path prefix for getting at branches via their short name.
 BRANCH_ALIAS_PREFIX = '+branch'
+# The path prefix for getting at branches via their id.
+BRANCH_ID_ALIAS_PREFIX = '+branch-id'
+
 
 # The scheme types that are supported for codehosting.
 SUPPORTED_SCHEMES = 'bzr+ssh', 'http'

=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py	2011-03-29 00:11:57 +0000
+++ lib/lp/code/model/branch.py	2011-04-06 17:31:46 +0000
@@ -118,7 +118,10 @@
 from lp.code.interfaces.branchnamespace import IBranchNamespacePolicy
 from lp.code.interfaces.branchpuller import IBranchPuller
 from lp.code.interfaces.branchtarget import IBranchTarget
-from lp.code.interfaces.codehosting import compose_public_url
+from lp.code.interfaces.codehosting import (
+    BRANCH_ID_ALIAS_PREFIX,
+    compose_public_url,
+    )
 from lp.code.interfaces.seriessourcepackagebranch import (
     IFindOfficialBranchLinks,
     )
@@ -997,6 +1000,17 @@
         self.last_mirror_attempt = UTC_NOW
         self.next_mirror_time = None
 
+    def _findStackedBranch(self, stacked_on_location):
+        location = stacked_on_location.strip('/')
+        if location.startswith(BRANCH_ID_ALIAS_PREFIX + '/'):
+            try:
+                branch_id = int(location.split('/', 1)[1])
+            except (ValueError, IndexError):
+                return None
+            return getUtility(IBranchLookup).get(branch_id)
+        else:
+            return getUtility(IBranchLookup).getByUniqueName(location)
+
     def branchChanged(self, stacked_on_location, last_revision_id,
                       control_format, branch_format, repository_format):
         """See `IBranch`."""
@@ -1004,8 +1018,7 @@
         if stacked_on_location == '' or stacked_on_location is None:
             stacked_on_branch = None
         else:
-            stacked_on_branch = getUtility(IBranchLookup).getByUniqueName(
-                stacked_on_location.strip('/'))
+            stacked_on_branch = self._findStackedBranch(stacked_on_location)
             if stacked_on_branch is None:
                 self.mirror_status_message = (
                     'Invalid stacked on location: ' + stacked_on_location)

=== modified file 'lib/lp/code/model/branchlookup.py'
--- lib/lp/code/model/branchlookup.py	2011-02-24 15:30:54 +0000
+++ lib/lp/code/model/branchlookup.py	2011-04-06 17:31:46 +0000
@@ -46,6 +46,7 @@
     ILinkedBranchTraverser,
     )
 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
+from lp.code.interfaces.codehosting import BRANCH_ID_ALIAS_PREFIX
 from lp.code.interfaces.linkedbranch import get_linked_to_branch
 from lp.code.model.branch import Branch
 from lp.registry.errors import (
@@ -288,14 +289,29 @@
             return None
         return self._getBranchInNamespace(namespace_data, branch_name)
 
-    def getIdAndTrailingPath(self, path, from_slave=False):
-        """See `IBranchLookup`. """
-        if from_slave:
-            store = ISlaveStore(Branch)
+    def _getIdAndTrailingPathByIdAlias(self, store, path):
+        """Query by the integer id."""
+        parts = path.split('/', 2)
+        try:
+            branch_id = int(parts[1])
+        except (ValueError, IndexError):
+            return None, None
+        result = store.find(
+            (Branch.id),
+            Branch.id == branch_id,
+            Branch.private == False).one()
+        if result is None:
+            return None, None
         else:
-            store = IMasterStore(Branch)
+            try:
+                return branch_id, '/' + parts[2]
+            except IndexError:
+                return branch_id, ''
+
+    def _getIdAndTrailingPathByUniqueName(self, store, path):
+        """Query based on the unique name."""
         prefixes = []
-        for first, second in iter_split(path[1:], '/'):
+        for first, second in iter_split(path, '/'):
             prefixes.append(first)
         result = store.find(
             (Branch.id, Branch.unique_name),
@@ -304,9 +320,21 @@
             return None, None
         else:
             branch_id, unique_name = result
-            trailing = path[len(unique_name) + 1:]
+            trailing = path[len(unique_name):]
             return branch_id, trailing
 
+    def getIdAndTrailingPath(self, path, from_slave=False):
+        """See `IBranchLookup`. """
+        if from_slave:
+            store = ISlaveStore(Branch)
+        else:
+            store = IMasterStore(Branch)
+        path = path.lstrip('/')
+        if path.startswith(BRANCH_ID_ALIAS_PREFIX):
+            return self._getIdAndTrailingPathByIdAlias(store, path)
+        else:
+            return self._getIdAndTrailingPathByUniqueName(store, path)
+
     def _getBranchInNamespace(self, namespace_data, branch_name):
         if namespace_data['product'] == '+junk':
             return self._getPersonalBranch(

=== modified file 'lib/lp/code/model/tests/test_branch.py'
--- lib/lp/code/model/tests/test_branch.py	2011-03-29 00:11:57 +0000
+++ lib/lp/code/model/tests/test_branch.py	2011-04-06 17:31:46 +0000
@@ -79,6 +79,7 @@
     )
 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
 from lp.code.interfaces.branchrevision import IBranchRevision
+from lp.code.interfaces.codehosting import BRANCH_ID_ALIAS_PREFIX
 from lp.code.interfaces.linkedbranch import ICanHasLinkedBranch
 from lp.code.interfaces.seriessourcepackagebranch import (
     IFindOfficialBranchLinks,
@@ -180,6 +181,17 @@
             stacked_on.unique_name, '', *self.arbitrary_formats)
         self.assertEqual(stacked_on, branch.stacked_on)
 
+    def test_branchChanged_sets_stacked_on_branch_id_alias(self):
+        # branchChanged sets the stacked_on attribute based on the id of the
+        # branch if it is valid.
+        branch = self.factory.makeAnyBranch()
+        stacked_on = self.factory.makeAnyBranch()
+        login_person(branch.owner)
+        stacked_on_location = '/%s/%s' % (
+            BRANCH_ID_ALIAS_PREFIX, stacked_on.id)
+        branch.branchChanged(stacked_on_location, '', *self.arbitrary_formats)
+        self.assertEqual(stacked_on, branch.stacked_on)
+
     def test_branchChanged_unsets_stacked_on(self):
         # branchChanged clears the stacked_on attribute on the branch if '' is
         # passed in as the stacked_on location.

=== modified file 'lib/lp/code/model/tests/test_branchlookup.py'
--- lib/lp/code/model/tests/test_branchlookup.py	2010-10-24 21:00:11 +0000
+++ lib/lp/code/model/tests/test_branchlookup.py	2011-04-06 17:31:46 +0000
@@ -25,6 +25,7 @@
     ILinkedBranchTraverser,
     )
 from lp.code.interfaces.branchnamespace import get_branch_namespace
+from lp.code.interfaces.codehosting import BRANCH_ID_ALIAS_PREFIX
 from lp.code.interfaces.linkedbranch import ICanHasLinkedBranch
 from lp.registry.errors import (
     NoSuchDistroSeries,
@@ -38,6 +39,7 @@
     )
 from lp.registry.interfaces.productseries import NoSuchProductSeries
 from lp.testing import (
+    person_logged_in,
     run_with_login,
     TestCaseWithFactory,
     )
@@ -118,6 +120,48 @@
             '/' + branch.unique_name + '/' + path)
         self.assertEqual((branch.id, '/' + path), result)
 
+    def test_branch_id_alias(self):
+        # The prefix by itself returns no branch, and no path.
+        path = BRANCH_ID_ALIAS_PREFIX
+        result = self.branch_set.getIdAndTrailingPath('/' + path)
+        self.assertEqual((None, None), result)
+
+    def test_branch_id_alias_not_int(self):
+        # The prefix followed by a non-integer returns no branch and no path.
+        path = BRANCH_ID_ALIAS_PREFIX + '/foo'
+        result = self.branch_set.getIdAndTrailingPath('/' + path)
+        self.assertEqual((None, None), result)
+
+    def test_branch_id_alias_private(self):
+        # Private branches are not found at all (this is for anonymous access)
+        owner = self.factory.makePerson()
+        branch = self.factory.makeAnyBranch(owner=owner, private=True)
+        with person_logged_in(owner):
+            path = '/%s/%s' % (BRANCH_ID_ALIAS_PREFIX, branch.id)
+        result = self.branch_set.getIdAndTrailingPath(path)
+        self.assertEqual((None, None), result)
+
+    def test_branch_id_alias_public(self):
+        # Public branches can be accessed.
+        branch = self.factory.makeAnyBranch()
+        path = '/%s/%s' % (BRANCH_ID_ALIAS_PREFIX, branch.id)
+        result = self.branch_set.getIdAndTrailingPath(path)
+        self.assertEqual((branch.id, ''), result)
+
+    def test_branch_id_alias_public_slash(self):
+        # A trailing slash is returned as the extra path.
+        branch = self.factory.makeAnyBranch()
+        path = '/%s/%s/' % (BRANCH_ID_ALIAS_PREFIX, branch.id)
+        result = self.branch_set.getIdAndTrailingPath(path)
+        self.assertEqual((branch.id, '/'), result)
+
+    def test_branch_id_alias_public_with_path(self):
+        # All the path after the number is returned as the trailing path.
+        branch = self.factory.makeAnyBranch()
+        path = '/%s/%s/foo' % (BRANCH_ID_ALIAS_PREFIX, branch.id)
+        result = self.branch_set.getIdAndTrailingPath(path)
+        self.assertEqual((branch.id, '/foo'), result)
+
 
 class TestGetByPath(TestCaseWithFactory):
     """Test `IBranchLookup.getByLPPath`."""

=== modified file 'lib/lp/code/xmlrpc/codehosting.py'
--- lib/lp/code/xmlrpc/codehosting.py	2011-02-24 15:30:54 +0000
+++ lib/lp/code/xmlrpc/codehosting.py	2011-04-06 17:31:46 +0000
@@ -59,6 +59,7 @@
 from lp.code.interfaces.branchtarget import IBranchTarget
 from lp.code.interfaces.codehosting import (
     BRANCH_ALIAS_PREFIX,
+    BRANCH_ID_ALIAS_PREFIX,
     BRANCH_TRANSPORT,
     CONTROL_TRANSPORT,
     ICodehostingAPI,
@@ -287,7 +288,8 @@
 
         return run_with_login(login_id, branch_changed)
 
-    def _serializeBranch(self, requester, branch, trailing_path):
+    def _serializeBranch(self, requester, branch, trailing_path,
+                         force_readonly=False):
         if requester == LAUNCHPAD_SERVICES:
             branch = removeSecurityProxy(branch)
         try:
@@ -296,10 +298,13 @@
             raise faults.PermissionDenied()
         if branch.branch_type == BranchType.REMOTE:
             return None
+        if force_readonly:
+            writable = False
+        else:
+            writable = self._canWriteToBranch(requester, branch)
         return (
             BRANCH_TRANSPORT,
-            {'id': branch_id,
-             'writable': self._canWriteToBranch(requester, branch)},
+            {'id': branch_id, 'writable': writable},
             trailing_path)
 
     def _serializeControlDirectory(self, requester, product_path,
@@ -323,12 +328,34 @@
             {'default_stack_on': escape('/' + unique_name)},
             trailing_path)
 
+    def _translateBranchIdAlias(self, requester, path):
+        # If the path isn't a branch id alias, nothing more to do.
+        stripped_path = unescape(path.strip('/'))
+        if not stripped_path.startswith(BRANCH_ID_ALIAS_PREFIX + '/'):
+            return None
+        try:
+            parts = stripped_path.split('/', 2)
+            branch_id = int(parts[1])
+        except (ValueError, IndexError):
+            raise faults.PathTranslationError(path)
+        branch = getUtility(IBranchLookup).get(branch_id)
+        if branch is None:
+            raise faults.PathTranslationError(path)
+        try:
+            trailing = parts[2]
+        except IndexError:
+            trailing = ''
+        return self._serializeBranch(requester, branch, trailing, True)
+
     def translatePath(self, requester_id, path):
         """See `ICodehostingAPI`."""
         @return_fault
         def translate_path(requester):
             if not path.startswith('/'):
                 return faults.InvalidPath(path)
+            branch = self._translateBranchIdAlias(requester, path)
+            if branch is not None:
+                return branch
             stripped_path = path.strip('/')
             for first, second in iter_split(stripped_path, '/'):
                 first = unescape(first)

=== modified file 'lib/lp/code/xmlrpc/tests/test_codehosting.py'
--- lib/lp/code/xmlrpc/tests/test_codehosting.py	2010-10-13 03:56:46 +0000
+++ lib/lp/code/xmlrpc/tests/test_codehosting.py	2011-04-06 17:31:46 +0000
@@ -41,6 +41,7 @@
 from lp.code.interfaces.branchtarget import IBranchTarget
 from lp.code.interfaces.codehosting import (
     BRANCH_ALIAS_PREFIX,
+    BRANCH_ID_ALIAS_PREFIX,
     BRANCH_TRANSPORT,
     CONTROL_TRANSPORT,
     )
@@ -991,6 +992,63 @@
         requester = self.factory.makePerson()
         self.assertNotFound(requester, '/%s/.bzr' % BRANCH_ALIAS_PREFIX)
 
+    def test_translatePath_branch_id_alias_bzrdir_content(self):
+        # translatePath('/+branch-id/.bzr/.*') *must* return not found, otherwise
+        # bzr will look for it and we don't have a global bzr dir.
+        requester = self.factory.makePerson()
+        self.assertNotFound(
+            requester, '/%s/.bzr/branch-format' % BRANCH_ID_ALIAS_PREFIX)
+
+    def test_translatePath_branch_id_alias_bzrdir(self):
+        # translatePath('/+branch-id/.bzr') *must* return not found, otherwise
+        # bzr will look for it and we don't have a global bzr dir.
+        requester = self.factory.makePerson()
+        self.assertNotFound(requester, '/%s/.bzr' % BRANCH_ID_ALIAS_PREFIX)
+
+    def test_translatePath_branch_id_alias_trailing(self):
+        # Make sure the trailing path is returned.
+        requester = self.factory.makePerson()
+        branch = removeSecurityProxy(self.factory.makeAnyBranch())
+        path = escape(u'/%s/%s/foo/bar' % (BRANCH_ID_ALIAS_PREFIX, branch.id))
+        translation = self.codehosting_api.translatePath(requester.id, path)
+        self.assertEqual(
+            (BRANCH_TRANSPORT, {'id': branch.id, 'writable': False}, 'foo/bar'),
+            translation)
+
+    def test_translatePath_branch_id_alias_owned(self):
+        # Even if the the requester is the owner, the branch is read only.
+        requester = self.factory.makePerson()
+        branch = removeSecurityProxy(
+            self.factory.makeAnyBranch(
+                branch_type=BranchType.HOSTED, owner=requester))
+        path = escape(u'/%s/%s' % (BRANCH_ID_ALIAS_PREFIX, branch.id))
+        translation = self.codehosting_api.translatePath(requester.id, path)
+        self.assertEqual(
+            (BRANCH_TRANSPORT, {'id': branch.id, 'writable': False}, ''),
+            translation)
+
+    def test_translatePath_branch_id_alias_private_branch(self):
+        # Private branches are accessible but read-only even if you are the
+        # owner.
+        requester = self.factory.makePerson()
+        branch = removeSecurityProxy(
+            self.factory.makeAnyBranch(
+                branch_type=BranchType.HOSTED, private=True, owner=requester))
+        path = escape(u'/%s/%s' % (BRANCH_ID_ALIAS_PREFIX, branch.id))
+        translation = self.codehosting_api.translatePath(requester.id, path)
+        self.assertEqual(
+            (BRANCH_TRANSPORT, {'id': branch.id, 'writable': False}, ''),
+            translation)
+
+    def test_translatePath_branch_id_alias_private_branch_no_access(self):
+        # Private branches you don't have access to raise permission denied.
+        requester = self.factory.makePerson()
+        branch = removeSecurityProxy(
+            self.factory.makeAnyBranch(
+                branch_type=BranchType.HOSTED, private=True))
+        path = escape(u'/%s/%s' % (BRANCH_ID_ALIAS_PREFIX, branch.id))
+        self.assertPermissionDenied(requester, path)
+
     def assertTranslationIsControlDirectory(self, translation,
                                             default_stacked_on,
                                             trailing_path):

=== modified file 'lib/lp/codehosting/inmemory.py'
--- lib/lp/codehosting/inmemory.py	2011-02-24 15:30:54 +0000
+++ lib/lp/codehosting/inmemory.py	2011-04-06 17:31:46 +0000
@@ -37,6 +37,7 @@
 from lp.code.interfaces.branchtarget import IBranchTarget
 from lp.code.interfaces.codehosting import (
     BRANCH_ALIAS_PREFIX,
+    BRANCH_ID_ALIAS_PREFIX,
     BRANCH_TRANSPORT,
     CONTROL_TRANSPORT,
     LAUNCHPAD_ANONYMOUS,
@@ -806,21 +807,46 @@
             {'default_stack_on': escape('/' + default_branch.unique_name)},
             trailing_path)
 
-    def _serializeBranch(self, requester_id, branch, trailing_path):
+    def _serializeBranch(self, requester_id, branch, trailing_path,
+                         force_readonly=False):
         if not self._canRead(requester_id, branch):
             return faults.PermissionDenied()
         elif branch.branch_type == BranchType.REMOTE:
             return None
+        if force_readonly:
+            writable = False
         else:
-            return (
-                BRANCH_TRANSPORT,
-                {'id': branch.id,
-                 'writable': self._canWrite(requester_id, branch),
-                 }, trailing_path)
+            writable = self._canWrite(requester_id, branch)
+        return (
+            BRANCH_TRANSPORT,
+            {'id': branch.id, 'writable': writable},
+            trailing_path)
+
+    def _translateBranchIdAlias(self, requester, path):
+        # If the path isn't a branch id alias, nothing more to do.
+        stripped_path = unescape(path.strip('/'))
+        if not stripped_path.startswith(BRANCH_ID_ALIAS_PREFIX + '/'):
+            return None
+        try:
+            parts = stripped_path.split('/', 2)
+            branch_id = int(parts[1])
+        except (ValueError, IndexError):
+            return faults.PathTranslationError(path)
+        branch = self._branch_set.get(branch_id)
+        if branch is None:
+            return faults.PathTranslationError(path)
+        try:
+            trailing = parts[2]
+        except IndexError:
+            trailing = ''
+        return self._serializeBranch(requester, branch, trailing, True)
 
     def translatePath(self, requester_id, path):
         if not path.startswith('/'):
             return faults.InvalidPath(path)
+        branch = self._translateBranchIdAlias(requester_id, path)
+        if branch is not None:
+            return branch
         stripped_path = path.strip('/')
         for first, second in iter_split(stripped_path, '/'):
             first = unescape(first).encode('utf-8')

=== modified file 'lib/lp/codehosting/rewrite.py'
--- lib/lp/codehosting/rewrite.py	2010-08-20 20:31:18 +0000
+++ lib/lp/codehosting/rewrite.py	2011-04-06 17:31:46 +0000
@@ -11,6 +11,7 @@
 
 from canonical.config import config
 from lp.code.interfaces.branchlookup import IBranchLookup
+from lp.code.interfaces.codehosting import BRANCH_ID_ALIAS_PREFIX
 from lp.codehosting.vfs import branch_id_to_path
 from lp.services.utils import iter_split
 
@@ -99,7 +100,10 @@
             branch_id, trailing, cached = self._getBranchIdAndTrailingPath(
                 resource_location)
             if branch_id is None:
-                r = self._codebrowse_url(resource_location)
+                if resource_location.startswith('/' + BRANCH_ID_ALIAS_PREFIX):
+                    r = 'NULL'
+                else:
+                    r = self._codebrowse_url(resource_location)
             else:
                 if trailing.startswith('/.bzr'):
                     r = urlutils.join(

=== modified file 'lib/lp/codehosting/tests/test_rewrite.py'
--- lib/lp/codehosting/tests/test_rewrite.py	2010-12-23 01:02:00 +0000
+++ lib/lp/codehosting/tests/test_rewrite.py	2011-04-06 17:31:46 +0000
@@ -16,6 +16,7 @@
 
 from canonical.config import config
 from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.code.interfaces.codehosting import BRANCH_ID_ALIAS_PREFIX
 from lp.codehosting.rewrite import BranchRewriter
 from lp.codehosting.vfs import branch_id_to_path
 from lp.services.log.logger import BufferLogger
@@ -91,6 +92,56 @@
              'http://localhost:8080/%s/.bzr' % unique_name],
             output)
 
+    def test_rewriteLine_id_alias_found_dot_bzr(self):
+        # Requests for /+branch-id/$id/.bzr/... are redirected to where the
+        # branches are served from by ID if they are public.
+        rewriter = self.makeRewriter()
+        branches = [
+            self.factory.makeProductBranch(private=False),
+            self.factory.makePersonalBranch(private=False),
+            self.factory.makePackageBranch(private=False)]
+        transaction.commit()
+        output = [
+            rewriter.rewriteLine("/%s/%s/.bzr/README" % (
+                    BRANCH_ID_ALIAS_PREFIX, branch.id))
+            for branch in branches]
+        expected = [
+            'file:///var/tmp/bazaar.launchpad.dev/mirrors/%s/.bzr/README'
+            % branch_id_to_path(branch.id)
+            for branch in branches]
+        self.assertEqual(expected, output)
+
+    def test_rewriteLine_id_alias_private(self):
+        # All requests for /+branch-id/$id/... for private branches return
+        # 'NULL'.  This is translated by apache to a 404.
+        rewriter = self.makeRewriter()
+        branch = self.factory.makeAnyBranch(private=True)
+        path = '/%s/%s' % (
+            BRANCH_ID_ALIAS_PREFIX, removeSecurityProxy(branch).id)
+        transaction.commit()
+        output = [
+            rewriter.rewriteLine("%s/changes" % path),
+            rewriter.rewriteLine("%s/.bzr" % path)
+            ]
+        self.assertEqual(['NULL', 'NULL'], output)
+
+    def test_rewriteLine_id_alias_logs_cache_hit(self):
+        # The second request for a branch using the alias hits the cache.
+        rewriter = self.makeRewriter()
+        branch = self.factory.makeAnyBranch()
+        transaction.commit()
+        path = "/%s/%s/.bzr/README" % (BRANCH_ID_ALIAS_PREFIX, branch.id)
+        rewriter.rewriteLine(path)
+        rewriter.rewriteLine(path)
+        logging_output_lines = self.getLoggerOutput(
+            rewriter).strip().split('\n')
+        self.assertEqual(2, len(logging_output_lines))
+        self.assertIsNot(
+            None,
+            re.match("INFO .* -> .* (.*s, cache: HIT)",
+                     logging_output_lines[-1]),
+            "No hit found in %r" % logging_output_lines[-1])
+
     def test_rewriteLine_static(self):
         # Requests to /static are rewritten to codebrowse urls.
         rewriter = self.makeRewriter()

=== modified file 'lib/lp/registry/javascript/structural-subscription.js'
--- lib/lp/registry/javascript/structural-subscription.js	2011-04-05 17:19:14 +0000
+++ lib/lp/registry/javascript/structural-subscription.js	2011-04-06 17:31:46 +0000
@@ -164,20 +164,27 @@
  * @method create_structural_subscription filter
  * @param {Object} who Link to the user or team to be subscribed.
  * @param {Object} form_data The data returned from the form submission.
+ * @param {Object} success_callback Function to execute when filter is added.
  */
-function add_bug_filter(who, form_data) {
+function add_bug_filter(who, form_data, success_callback) {
     var config = {
         on: {success: function (bug_filter) {
                 // If we fail to PATCH the new bug filter, DELETE it.
-                var on = {failure: function () {
-                    // We use the namespace binding so tests can override
-                    // these functions.
-                    namespace._delete_filter(bug_filter);
-                    // Call the failure handler to report the original
-                    // error to the user.
-                    overlay_error_handler.getFailureHandler()
-                        .apply(this, arguments);
-                }};
+                var on = {
+                    failure: function () {
+                        // We use the namespace binding so tests can override
+                        // these functions.
+                        namespace._delete_filter(bug_filter);
+                        // Call the failure handler to report the original
+                        // error to the user.
+                        overlay_error_handler.getFailureHandler()
+                            .apply(this, arguments);
+                    },
+                    success: function (bug_filter) {
+                        success_callback(form_data, bug_filter);
+                        subscription_success(bug_filter);
+                    }
+                };
                 patch_bug_filter(bug_filter.getAttrs(), form_data, on);
             },
             failure: overlay_error_handler.getFailureHandler()
@@ -195,28 +202,31 @@
 namespace._add_bug_filter = add_bug_filter;
 
 /**
- * Given the form data from a user, save the subscription.
+ * Create a handler to save the subscription given the form data from a user.
  *
  * @private
- * @method save_subscription
- * @param {Object} form_data The data generated by the form submission.
+ * @method make_add_subscription_handler
+ * @param {Object} success_callback Function to execute on successful addition.
  */
-
-function save_subscription(form_data) {
-    var who;
-    var has_errors = check_for_errors_in_overlay(add_subscription_overlay);
-    if (has_errors) {
-        return false;
-    }
-    if (form_data.recipient[0] === 'user') {
-        who = LP.links.me;
-    } else {
-        // There can be only one.
-        who = form_data.team[0];
-    }
-    add_bug_filter(who, form_data);
+function make_add_subscription_handler(success_callback) {
+    var save_subscription = function(form_data) {
+        var who;
+        var has_errors = check_for_errors_in_overlay(
+            add_subscription_overlay);
+        if (has_errors) {
+            return false;
+        }
+        if (form_data.recipient[0] === 'user') {
+            who = LP.links.me;
+        } else {
+            // There can be only one.
+            who = form_data.team[0];
+        }
+        return add_bug_filter(who, form_data, success_callback);
+    };
+    return save_subscription;
 }
-namespace.save_subscription = save_subscription;
+namespace._make_add_subscription_handler = make_add_subscription_handler;
 
 function check_for_errors_in_overlay(overlay) {
     var has_errors = false;
@@ -245,6 +255,18 @@
 }
 
 /**
+ * Fill the filter name and description.
+ */
+function fill_filter_description(filter_node, filter_info, filter) {
+    filter_node.one('.filter-description')
+        .empty()
+        .appendChild(create_filter_description(filter));
+    filter_node.one('.filter-name')
+        .empty()
+        .appendChild(render_filter_title(filter_info, filter));
+};
+
+/**
  * Handle the activation of the edit subscription link.
  */
 function edit_subscription_handler(context, form_data) {
@@ -256,12 +278,8 @@
     }
     var on = {success: function (new_data) {
         var filter = new_data.getAttrs();
-        filter_node.one('.filter-description')
-            .empty()
-            .appendChild(create_filter_description(filter));
-        filter_node.one('.filter-name')
-            .empty()
-            .appendChild(render_filter_title(context.filter_info, filter));
+        fill_filter_description(
+            filter_node, context.filter_info, filter);
         add_subscription_overlay.hide();
     }};
     patch_bug_filter(context.filter_info.filter, form_data, on);
@@ -279,7 +297,7 @@
  * Populate the overlay element with the contents of the add/edit form.
  */
 function create_overlay(content_box_id, overlay_id, submit_button,
-        submit_callback) {
+                        submit_callback, success_callback) {
     // Create the overlay.
     add_subscription_overlay = new Y.lazr.FormOverlay({
         headerContent:
@@ -540,14 +558,18 @@
         });
     });
     anim.on("end", function() {
-        node.setStyles({
-            height: 0,
-            visibility: 'hidden',
-            display: null
-            // Don't set display: none because then the node won't be taken
-            // into account and the rendering will sometimes jiggle
-            // horizontally when the node is opened.
-        });
+        if (user_cfg && user_cfg.remove_on_end === true) {
+            node.remove();
+        } else {
+            node.setStyles({
+                height: 0,
+                visibility: 'hidden',
+                display: null
+                // Don't set display: none because then the node won't be taken
+                // into account and the rendering will sometimes jiggle
+                // horizontally when the node is opened.
+            });
+        }
     });
     anim.run();
 }
@@ -883,13 +905,8 @@
     var recipient_label = content_node.one('input[name="recipient"] + span'),
         teams = LP.cache.administratedTeams;
     if (filter_info !== undefined && filter_info.subscriber_is_team) {
-        var i;
-        for (i=0; i<teams.length; i++) {
-            if (teams[i].link === filter_info.subscriber_link){
-                recipient_label.set('text', teams[i].title);
-                break;
-            }
-        }
+        var team = get_team(filter_info.subscriber_link);
+        recipient_label.set('text', team.title);
     } else {
         recipient_label.set('text', 'Yourself');
     }
@@ -1032,7 +1049,7 @@
 /**
  * Construct a handler for an unsubscribe link.
  */
-function make_delete_handler(filter, node, subscriber_id) {
+function make_delete_handler(filter, filter_id, node, subscriber_id) {
     var error_handler = new Y.lp.client.ErrorHandler();
     error_handler.showError = function(error_msg) {
       var unsubscribe_node = node.one('a.delete-subscription');
@@ -1042,19 +1059,24 @@
         var y_config = {
             method: "POST",
             headers: {'X-HTTP-Method-Override': 'DELETE'},
-            on: {success: function(transactionid, response, args){
+            on: {
+                success: function(transactionid, response, args){
+                    var filter_node = Y.one(
+                        '#subscription-filter-'+filter_id.toString());
+                    filter_node.setStyle("margin-top", "0");
                     var subscriber = Y.one(
                         '#subscription-'+subscriber_id.toString());
-                    var to_collapse = subscriber;
                     var filters = subscriber.all('.subscription-filter');
-                    if (!filters.isEmpty()) {
-                        to_collapse = node;
+
+                    collapse_node(filter_node, { remove_on_end: true });
+                    if (filters.size() <= 1) {
+                        collapse_node(subscriber, { remove_on_end: true });
+                        LP.cache.subscription_info[subscriber_id].filters = [];
                     }
-                    collapse_node(to_collapse);
-                    },
-                 failure: error_handler.getFailureHandler()
-                }
-            };
+                },
+                failure: error_handler.getFailureHandler()
+            }
+        };
         Y.io(filter.self_link, y_config);
     };
 }
@@ -1093,6 +1115,31 @@
 }
 
 /**
+ * Attach activation (click) handlers to links for a particular filter.
+ */
+function wire_up_edit_links_for_filter(
+    config, subscription, subscription_id, filter_info, filter_id,
+    filter_node) {
+    var node = filter_node || Y.one(
+        '#subscription-filter-'+filter_id.toString());
+    if (filter_info.can_mute) {
+        var mute_link = node.one('a.mute-subscription');
+        mute_link.on('click', make_mute_handler(filter_info, node));
+    }
+    if (!filter_info.subscriber_is_team ||
+        filter_info.user_is_team_admin) {
+        var edit_link = node.one('a.edit-subscription');
+        var edit_handler = make_edit_handler(
+            subscription, filter_info, filter_id, config);
+        edit_link.on('click', edit_handler);
+        var delete_link = node.one('a.delete-subscription');
+        var delete_handler = make_delete_handler(
+            filter_info.filter, filter_id, node, subscription_id);
+        delete_link.on('click', delete_handler);
+    }
+}
+
+/**
  * Attach activation (click) handlers to all of the edit links on the page.
  */
 function wire_up_edit_links(config) {
@@ -1105,22 +1152,8 @@
         var sub = subscription_info[i];
         for (j=0; j<sub.filters.length; j++) {
             var filter_info = sub.filters[j];
-            var node = Y.one('#subscription-filter-'+filter_id.toString());
-            if (filter_info.can_mute) {
-                var mute_link = node.one('a.mute-subscription');
-                mute_link.on('click', make_mute_handler(filter_info, node));
-            }
-            if (!filter_info.subscriber_is_team ||
-                filter_info.user_is_team_admin) {
-                var edit_link = node.one('a.edit-subscription');
-                var edit_handler = make_edit_handler(
-                    sub, filter_info, filter_id, config);
-                edit_link.on('click', edit_handler);
-                var delete_link = node.one('a.delete-subscription');
-                var delete_handler = make_delete_handler(
-                    filter_info.filter, node, i);
-                delete_link.on('click', delete_handler);
-            }
+            wire_up_edit_links_for_filter(
+                config, sub, i, filter_info, filter_id);
             filter_id += 1;
         }
     }
@@ -1147,6 +1180,97 @@
 }
 
 /**
+ * Create filter node to include in the subscription's filter listing.
+ */
+function create_filter_node(filter_id, filter_info, filter) {
+    var filter_node = Y.Node.create(
+        '<div style="margin: 1em 0em 0em 1em"'+
+            '      class="subscription-filter"></div>')
+        .set('id', 'subscription-filter-'+filter_id.toString());
+    filter_node.appendChild(Y.Node.create(
+        '<div style="margin-top: 1em"></div>'));
+    filter_node.appendChild(Y.Node.create(
+        '<strong class="filter-name"></strong>'));
+
+    if (filter_info.can_mute) {
+        filter_node.appendChild(Y.Node.create(
+            '<em class="mute-label" style="padding-left: 1em;">You '+
+                'do not receive emails from this subscription.</em>'));
+    }
+
+    var can_edit = (!filter_info.subscriber_is_team ||
+                    filter_info.user_is_team_admin);
+
+    // Whitespace is stripped from the left and right of the string
+    // when you make a node, so we have to build the string with the
+    // intermediate whitespace and then create the node at the end.
+    var control_template = '';
+
+    if (filter_info.can_mute) {
+        control_template += (
+            '<a href="#" class="sprite js-action mute-subscription"></a>');
+        if (can_edit) {
+            control_template += ' or ';
+        }
+    }
+    if (can_edit) {
+        // User can edit the subscription.
+        control_template += (
+            '<a href="#" class="sprite modify edit js-action '+
+                '    edit-subscription">Edit this subscription</a> or '+
+                '<a href="#" class="sprite modify remove js-action '+
+                '    delete-subscription">Unsubscribe</a>');
+    }
+
+    filter_node.appendChild(
+        Y.Node.create(
+            '<span style="float: right"></span>'))
+        .appendChild(Y.Node.create(control_template));
+    filter_node.appendChild(
+        Y.Node.create('<div style="padding-left: 1em" '+
+                      'class="filter-description"></div>'));
+
+    if (filter_info.can_mute) {
+        handle_mute(filter_node, filter_info.is_muted);
+    }
+
+    fill_filter_description(filter_node, filter_info, filter);
+
+    return filter_node;
+}
+
+/**
+ * Create a node with subscription description.
+ */
+function create_subscription_node(serial_id, subscription_data, filter_id) {
+    var node = Y.Node.create(
+        '<div style="margin-top: 2em; padding: 0 1em 1em 1em; '+
+            '      border: 1px solid #ddd;"></div>')
+        .set('id', 'subscription-'+serial_id.toString())
+    node.appendChild(Y.Node.create(
+        '  <span style="float: left; margin-top: -0.6em; '+
+            '      padding: 0 1ex; background-color: #fff;"></a>'))
+        .appendChild('<span>Subscriptions to </span>')
+        .appendChild(Y.Node.create('<a></a>')
+                     .set('href', subscription_data.target_url)
+                     .set('text', subscription_data.target_title));
+
+    for (j=0; j<subscription_data.filters.length; j++) {
+        var filter_info = subscription_data.filters[j];
+        var filter = filter_info.filter;
+        // We put the filters in the cache so that the patch mechanism
+        // can automatically find them and update them on a successful
+        // edit.  This makes it possible to open up a filter after an edit
+        // and see the information you expect to see.
+        LP.cache['structural-subscription-filter-'+filter_id.toString()] =
+            filter;
+        node.appendChild(create_filter_node(filter_id, filter_info, filter));
+        filter_id += 1;
+    };
+    return node;
+}
+
+/**
  * Populate the subscription list DOM element with subscription descriptions.
  */
 function fill_in_bug_subscriptions(config) {
@@ -1156,89 +1280,14 @@
     var subscription_info = LP.cache.subscription_info;
     var top_node = Y.Node.create(
         '<div class="yui-g"><div id="structural-subscriptions"></div></div>');
+    var i;
     var filter_id = 0;
-    var i;
-    var j;
     for (i=0; i<subscription_info.length; i++) {
-        var sub = subscription_info[i];
-        var sub_node = top_node.appendChild(Y.Node.create(
-            '<div style="margin-top: 2em; padding: 0 1em 1em 1em; '+
-            '      border: 1px solid #ddd;"></div>')
-            .set('id', 'subscription-'+i.toString()));
-        sub_node.appendChild(Y.Node.create(
-            '  <span style="float: left; margin-top: -0.6em; '+
-            '      padding: 0 1ex; background-color: #fff;"></a>'))
-            .appendChild('<span>Subscriptions to </span>')
-                .appendChild(Y.Node.create('<a></a>')
-                    .set('href', sub.target_url)
-                    .set('text', sub.target_title));
-
-        for (j=0; j<sub.filters.length; j++) {
-            var filter = sub.filters[j].filter;
-            // We put the filters in the cache so that the patch mechanism
-            // can automatically find them and update them on a successful
-            // edit.  This makes it possible to open up a filter after an edit
-            // and see the information you expect to see.
-            LP.cache['structural-subscription-filter-'+filter_id.toString()] =
-                filter;
-            var filter_node = sub_node.appendChild(Y.Node.create(
-                '<div style="margin: 1em 0em 0em 1em"'+
-                '      class="subscription-filter"></div>')
-                .set('id', 'subscription-filter-'+filter_id.toString()))
-                .appendChild(Y.Node.create(
-                    '<div style="margin-top: 1em"></div>'));
-            filter_node.appendChild(Y.Node.create(
-                '<strong class="filter-name"></strong>'))
-                .appendChild(render_filter_title(sub.filters[j], filter));
-            if (sub.filters[j].can_mute) {
-                filter_node.appendChild(Y.Node.create(
-                    '<em class="mute-label" style="padding-left: 1em;">You '+
-                    'do not receive emails from this subscription.</em>'));
-            }
-            var can_edit = (!sub.filters[j].subscriber_is_team ||
-                            sub.filters[j].user_is_team_admin);
-            // Whitespace is stripped from the left and right of the string
-            // when you make a node, so we have to build the string with the
-            // intermediate whitespace and then create the node at the end.
-            var control_template = '';
-            if (sub.filters[j].can_mute) {
-                control_template += (
-                    '<a href="#" class="sprite js-action '+
-                    'mute-subscription"></a>');
-                if (can_edit) {
-                    control_template += ' or ';
-                }
-            }
-            if (can_edit) {
-                // User can edit the subscription.
-                control_template += (
-                    '<a href="#" class="sprite modify edit js-action '+
-                    '    edit-subscription">Edit this subscription</a> or '+
-                    '<a href="#" class="sprite modify remove js-action '+
-                    '    delete-subscription">Unsubscribe</a>');
-            }
-            filter_node.appendChild(Y.Node.create(
-                '<span style="float: right"></span>')
-                ).appendChild(Y.Node.create(control_template));
-            filter_node.appendChild(Y.Node.create(
-                '<div style="padding-left: 1em" '+
-                'class="filter-description"></div>'))
-                .appendChild(create_filter_description(filter));
-            if (sub.filters[j].can_mute) {
-                handle_mute(filter_node, sub.filters[j].is_muted);
-            }
-
-            filter_id += 1;
-        }
-
-        // We can remove this once we enforce at least one filter per
-        // subscription.
-        if (subscription_info[i].filters.length === 0) {
-            sub_node.appendChild(
-                '<div style="clear: both; padding: 1em 0 0 1em"></div>')
-                .appendChild('<strong>All messages</strong>');
-        }
+        top_node.appendChild(
+            create_subscription_node(i, subscription_info[i], filter_id));
+        filter_id += subscription_info[i].filters.length;
     }
+
     listing.appendChild(top_node);
 
     wire_up_edit_links(config);
@@ -1272,7 +1321,6 @@
  */
 function create_filter_description(filter) {
     var description = Y.Node.create('<div></div>');
-
     var filter_items = [];
     // Format status conditions.
     if (filter.statuses.length !== 0) {
@@ -1372,20 +1420,154 @@
 }
 
 /**
+ * Get team information.
+ */
+function get_team(url) {
+    var teams = LP.cache.administratedTeams;
+    var i;
+    for (i=0; i<teams.length; i++) {
+        if (teams[i].link === url) {
+            return teams[i];
+        }
+    }
+}
+
+/**
+ * Get team information from the submitted form data.
+ */
+function get_team_info(form_data) {
+    var is_team = (form_data.recipient[0] == "team");
+    var link, team, title, url;
+    if (is_team) {
+        link = form_data.team[0];
+        team = get_team(link);
+        if (team !== undefined) {
+            title = team.title;
+            url = team.url;
+        } else {
+            is_team = false;
+            link = LP.links.me;
+        }
+    }
+    return {
+        is_team: is_team,
+        link: link,
+        title: title,
+        url: url}
+}
+
+/**
+ * Get target details from either target_info or first subscription_info.
+ */
+function get_target_info() {
+    if (LP.cache.target_info !== undefined) {
+        return LP.cache.target_info
+    } else {
+        var info = LP.cache.subscription_info[0];
+        return {
+            title: info.target_title,
+            url: info.target_url}
+    }
+}
+
+/**
+ * Constructs filter info on-the-go.
+ */
+function construct_filter_info(filter, form_data, target_info) {
+    var team_info = get_team_info(form_data);
+
+    var filter_info = {
+        filter : filter,
+        subscriber_is_team: team_info.is_team,
+        user_is_team_admin: team_info.is_team,
+        can_mute: team_info.is_team,
+        subscriber_url: team_info.url,
+        subscriber_link: team_info.link,
+        subscriber_title: team_info.title,
+        target_title: target_info.title,
+        target_url: target_info.url
+    };
+    return filter_info;
+}
+
+/**
+ * Return a function that adds the newly created filter to the
+ * subscription's filter list after it has been saved.
+ */
+
+function make_add_subscription_success_handler(config) {
+    return function(form_data, filter) {
+        if (config.add_filter_description === true) {
+            // This way to figure out the ID works only
+            // if the page shows exactly one "target" (eg. Firefox).
+            var subscriber_id = 0;
+            var filter_id;
+            var subscriptions_list = Y.one(
+                '#subscription-' + subscriber_id.toString());
+
+            var filter_data = filter.getAttrs();
+            var target_info = get_target_info();
+            var filter_info = construct_filter_info(
+                filter_data, form_data, target_info);
+
+            /* Node to flash with green on success. */
+            var anim_node;
+
+            var subscription_info;
+            if (subscriptions_list === null) {
+                // No subscriptions are listed at all.
+                filter_id = 0;
+                subscription_info = {
+                    filters: [filter_info],
+                    target_url: target_info.url,
+                    target_title: target_info.title
+                };
+                LP.cache.subscription_info = [subscription_info]
+
+                subscriptions_list = create_subscription_node(
+                    0, subscription_info, 0);
+                var subscriptions = Y.one("#structural-subscriptions");
+                subscriptions.appendChild(subscriptions_list);
+                anim_node = subscriptions_list.one("#subscription-filter-0");
+            } else {
+                // There's at least one filter in the page.
+                subscription_info = LP.cache.subscription_info[0];
+                filter_id = subscription_info.filters.length;
+                subscription_info.filters.push(filter);
+
+                var description_node = create_filter_node(
+                    filter_id, filter_info, filter_data);
+                subscriptions_list.append(description_node);
+                anim_node = description_node;
+            }
+
+            wire_up_edit_links_for_filter(
+                config, subscription_info, 0,
+                filter_info, filter_id, anim_node);
+            Y.lazr.anim.green_flash({node: anim_node}).run();
+        }
+    }
+}
+
+/**
  * Show the overlay for creating a new subscription.
  */
 function show_add_overlay(config) {
-    Y.one(config.content_box).empty();
+    var content_node = Y.one(config.content_box);
+    content_node.empty();
     var overlay_id = setup_overlay(config.content_box);
-    clear_overlay(Y.one(config.content_box), false);
+    clear_overlay(content_node, false);
 
     var submit_button = Y.Node.create(
         '<button type="submit" name="field.actions.create" ' +
         'value="Create subscription" class="lazr-pos lazr-btn" '+
         '>OK</button>');
 
+    var success_callback = make_add_subscription_success_handler(config);
+
+    var save_subscription = make_add_subscription_handler(success_callback);
     create_overlay(config.content_box, overlay_id, submit_button,
-        save_subscription);
+                   save_subscription, success_callback);
     // We need to initialize the help links.  They may have already been
     // initialized except for the ones we added, so setupHelpTrigger
     // is idempotent.  Notice that this is old MochiKit code.
@@ -1406,7 +1588,7 @@
     // Modify the menu-link-subscribe-to-bug-mail link to be visible.
     var link = Y.one(link_id);
     if (!Y.Lang.isValue(link)) {
-        Y.fail('Link to set as the pop-up link not found.');
+        Y.Assert.fail('Link to set as the pop-up link not found.');
     }
     link.removeClass('invisible-link');
     link.addClass('visible-link');
@@ -1416,6 +1598,7 @@
     });
     link.addClass('js-action');
 }                               // setup_subscription_links
+namespace.setup_subscription_link = setup_subscription_link;
 
 /**
  * External entry point for configuring the structural subscription.
@@ -1435,6 +1618,6 @@
 }; // setup
 
 }, '0.1', {requires: [
-        'dom', 'node', 'lazr.anim', 'lazr.formoverlay', 'lazr.overlay',
-        'lazr.effects', 'lp.app.errors', 'lp.client', 'gallery-accordion'
-    ]});
+    'dom', 'node', 'test', 'lazr.anim', 'lazr.formoverlay', 'lazr.overlay',
+    'lazr.effects', 'lp.app.errors', 'lp.client', 'gallery-accordion'
+]});

=== modified file 'lib/lp/registry/javascript/tests/test_structural_subscription.js'
--- lib/lp/registry/javascript/tests/test_structural_subscription.js	2011-04-05 18:29:45 +0000
+++ lib/lp/registry/javascript/tests/test_structural_subscription.js	2011-04-06 17:31:46 +0000
@@ -264,6 +264,10 @@
                 recipient: ['user']
             };
             context = {};
+
+            // Get the save subscription handler with empty success handler.
+            this.save_subscription = module._make_add_subscription_handler(
+                function() {});
         },
 
         tearDown: function() {
@@ -278,7 +282,7 @@
             module.setup(this.configuration);
             module._show_add_overlay(this.configuration);
             this.form_data.recipient = ['user'];
-            module.save_subscription(this.form_data);
+            this.save_subscription(this.form_data);
             Assert.areEqual(
                 LP.links.me,
                 context.config.parameters.subscriber);
@@ -291,7 +295,7 @@
             module._show_add_overlay(this.configuration);
             this.form_data.recipient = ['team'];
             this.form_data.team = ['https://launchpad.dev/api/~super-team'];
-            module.save_subscription(this.form_data);
+            this.save_subscription(this.form_data);
             Assert.areEqual(
                 this.form_data.team[0],
                 context.config.parameters.subscriber);