← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-subscriptions-browser into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-subscriptions-browser into lp:launchpad.

Commit message:
Add web UI for Git subscriptions, fixing the webservice along the way.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1444591 in Launchpad itself: "Allow Git repository subscriptions"
  https://bugs.launchpad.net/launchpad/+bug/1444591

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-subscriptions-browser/+merge/256900

Add web UI for Git subscriptions.  This also has the effect of fixing the webservice, which was failing because GitSubscription objects didn't have a URL; I decided it was easiest to just finish the job here.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-subscriptions-browser into lp:launchpad.
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2015-04-19 12:56:32 +0000
+++ lib/lp/code/browser/configure.zcml	2015-04-21 10:09:18 +0000
@@ -768,6 +768,35 @@
             name="++repository-management"
             template="../templates/gitrepository-management.pt"/>
     </browser:pages>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        permission="zope.Public"
+        name="+portlet-subscribers"
+        template="../templates/gitrepository-portlet-subscribers.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitsubscription.GitRepositoryPortletSubscribersContent"
+        permission="zope.Public"
+        name="+repository-portlet-subscriber-content"
+        template="../templates/gitrepository-portlet-subscribers-content.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitsubscription.GitSubscriptionAddView"
+        permission="launchpad.AnyPerson"
+        name="+subscribe"
+        template="../../app/templates/generic-edit.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitsubscription.GitSubscriptionAddOtherView"
+        permission="launchpad.AnyPerson"
+        name="+addsubscriber"
+        template="../../app/templates/generic-edit.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitsubscription.GitSubscriptionEditOwnView"
+        permission="launchpad.AnyPerson"
+        name="+edit-subscription"
+        template="../templates/gitrepository-edit-subscription.pt"/>
     <adapter
         provides="lp.services.webapp.interfaces.IBreadcrumb"
         for="lp.code.interfaces.gitrepository.IGitRepository"
@@ -804,6 +833,21 @@
         name="+ref-listing"
         template="../templates/gitref-listing.pt"/>
 
+    <browser:defaultView
+        for="lp.code.interfaces.gitsubscription.IGitSubscription"
+        name="+index"/>
+    <browser:page
+        for="lp.code.interfaces.gitsubscription.IGitSubscription"
+        class="lp.code.browser.gitsubscription.GitSubscriptionEditView"
+        permission="launchpad.Edit"
+        name="+index"
+        template="../templates/gitsubscription-edit.pt"/>
+    <browser:url
+        for="lp.code.interfaces.gitsubscription.IGitSubscription"
+        path_expression="string:+subscription/${person/name}"
+        attribute_to_parent="repository"
+        rootsite="code"/>
+
     <browser:menus
         classes="ProductBranchesMenu"
         module="lp.code.browser.branchlisting"/>

=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py	2015-03-24 13:10:52 +0000
+++ lib/lp/code/browser/gitrepository.py	2015-04-21 10:09:18 +0000
@@ -23,6 +23,7 @@
 from lp.services.config import config
 from lp.services.webapp import (
     ContextMenu,
+    enabled_with_permission,
     LaunchpadView,
     Link,
     Navigation,
@@ -83,7 +84,24 @@
 
     usedfor = IGitRepository
     facet = "branches"
-    links = ["source"]
+    links = ["add_subscriber", "source", "subscription"]
+
+    @enabled_with_permission("launchpad.AnyPerson")
+    def subscription(self):
+        if self.context.hasSubscription(self.user):
+            url = "+edit-subscription"
+            text = "Edit your subscription"
+            icon = "edit"
+        else:
+            url = "+subscribe"
+            text = "Subscribe yourself"
+            icon = "add"
+        return Link(url, text, icon=icon)
+
+    @enabled_with_permission("launchpad.AnyPerson")
+    def add_subscriber(self):
+        text = "Subscribe someone else"
+        return Link("+addsubscriber", text, icon="add")
 
     def source(self):
         """Return a link to the branch's browsing interface."""

=== added file 'lib/lp/code/browser/gitsubscription.py'
--- lib/lp/code/browser/gitsubscription.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/gitsubscription.py	2015-04-21 10:09:18 +0000
@@ -0,0 +1,293 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+__all__ = [
+    'GitRepositoryPortletSubscribersContent',
+    'GitSubscriptionAddOtherView',
+    'GitSubscriptionAddView',
+    'GitSubscriptionEditOwnView',
+    'GitSubscriptionEditView',
+    ]
+
+from zope.component import getUtility
+
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadEditFormView,
+    LaunchpadFormView,
+    )
+from lp.app.interfaces.services import IService
+from lp.code.enums import BranchSubscriptionNotificationLevel
+from lp.code.interfaces.gitsubscription import IGitSubscription
+from lp.registry.interfaces.person import IPersonSet
+from lp.services.webapp import (
+    canonical_url,
+    LaunchpadView,
+    )
+from lp.services.webapp.authorization import (
+    check_permission,
+    precache_permission_for_objects,
+    )
+from lp.services.webapp.escaping import structured
+
+
+class GitRepositoryPortletSubscribersContent(LaunchpadView):
+    """View for the contents for the subscribers portlet."""
+
+    def subscriptions(self):
+        """Return a decorated list of Git repository subscriptions."""
+
+        # Cache permissions so private subscribers can be rendered.
+        # The security adaptor will do the job also but we don't want or
+        # need the expense of running several complex SQL queries.
+        person_ids = [sub.person_id for sub in self.context.subscriptions]
+        list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
+            person_ids, need_validity=True))
+        if self.user is not None:
+            subscribers = [
+                subscription.person
+                for subscription in self.context.subscriptions]
+            precache_permission_for_objects(
+                self.request, "launchpad.LimitedView", subscribers)
+
+        visible_subscriptions = [
+            subscription for subscription in self.context.subscriptions
+            if check_permission("launchpad.LimitedView", subscription.person)]
+        return sorted(
+            visible_subscriptions,
+            key=lambda subscription: subscription.person.displayname)
+
+
+class _GitSubscriptionView(LaunchpadFormView):
+    """Contains the common functionality of the Add and Edit views."""
+
+    schema = IGitSubscription
+    field_names = ['notification_level', 'max_diff_lines', 'review_level']
+
+    LEVELS_REQUIRING_LINES_SPECIFICATION = (
+        BranchSubscriptionNotificationLevel.DIFFSONLY,
+        BranchSubscriptionNotificationLevel.FULL)
+
+    @property
+    def user_is_subscribed(self):
+        # Since it is technically possible to get to this page when
+        # the user is not subscribed by hacking the URL, we should
+        # handle the case nicely.
+        return self.context.getSubscription(self.user) is not None
+
+    @property
+    def next_url(self):
+        return canonical_url(self.context)
+
+    cancel_url = next_url
+
+    def add_notification_message(self, initial, notification_level,
+                                 max_diff_lines, review_level):
+        if notification_level in self.LEVELS_REQUIRING_LINES_SPECIFICATION:
+            lines_message = "<li>%s</li>" % max_diff_lines.description
+        else:
+            lines_message = ""
+
+        format_str = "%%s<ul><li>%%s</li>%s<li>%%s</li></ul>" % lines_message
+        message = structured(
+            format_str, initial, notification_level.description,
+            review_level.description)
+        self.request.response.addNotification(message)
+
+    def optional_max_diff_lines(self, notification_level, max_diff_lines):
+        if notification_level in self.LEVELS_REQUIRING_LINES_SPECIFICATION:
+            return max_diff_lines
+        else:
+            return None
+
+
+class GitSubscriptionAddView(_GitSubscriptionView):
+
+    subscribing_self = True
+
+    page_title = label = "Subscribe to repository"
+
+    @action("Subscribe")
+    def subscribe(self, action, data):
+        # To catch the stale post problem, check that the user is not
+        # subscribed before continuing.
+        if self.context.hasSubscription(self.user):
+            self.request.response.addNotification(
+                "You are already subscribed to this repository.")
+        else:
+            notification_level = data["notification_level"]
+            max_diff_lines = self.optional_max_diff_lines(
+                notification_level, data["max_diff_lines"])
+            review_level = data["review_level"]
+
+            self.context.subscribe(
+                self.user, notification_level, max_diff_lines, review_level,
+                self.user)
+
+            self.add_notification_message(
+                "You have subscribed to this repository with: ",
+                notification_level, max_diff_lines, review_level)
+
+
+class GitSubscriptionEditOwnView(_GitSubscriptionView):
+
+    @property
+    def label(self):
+        return "Edit subscription to repository"
+
+    @property
+    def page_title(self):
+        return "Edit subscription to repository %s" % self.context.displayname
+
+    @property
+    def initial_values(self):
+        subscription = self.context.getSubscription(self.user)
+        if subscription is None:
+            # This is the case of URL hacking or stale page.
+            return {}
+        else:
+            return {"notification_level": subscription.notification_level,
+                    "max_diff_lines": subscription.max_diff_lines,
+                    "review_level": subscription.review_level}
+
+    @action("Change")
+    def change_details(self, action, data):
+        # Be proactive in the checking to catch the stale post problem.
+        if self.context.hasSubscription(self.user):
+            subscription = self.context.getSubscription(self.user)
+            subscription.notification_level = data["notification_level"]
+            subscription.max_diff_lines = self.optional_max_diff_lines(
+                subscription.notification_level,
+                data["max_diff_lines"])
+            subscription.review_level = data["review_level"]
+
+            self.add_notification_message(
+                "Subscription updated to: ",
+                subscription.notification_level,
+                subscription.max_diff_lines,
+                subscription.review_level)
+        else:
+            self.request.response.addNotification(
+                "You are not subscribed to this repository.")
+
+    @action("Unsubscribe")
+    def unsubscribe(self, action, data):
+        # Be proactive in the checking to catch the stale post problem.
+        if self.context.hasSubscription(self.user):
+            self.context.unsubscribe(self.user, self.user)
+            self.request.response.addNotification(
+                "You have unsubscribed from this repository.")
+        else:
+            self.request.response.addNotification(
+                "You are not subscribed to this repository.")
+
+
+class GitSubscriptionAddOtherView(_GitSubscriptionView):
+    """View used to subscribe someone other than the current user."""
+
+    field_names = [
+        "person", "notification_level", "max_diff_lines", "review_level"]
+    for_input = True
+
+    # Since we are subscribing other people, the current user
+    # is never considered subscribed.
+    user_is_subscribed = False
+    subscribing_self = False
+
+    page_title = label = "Subscribe to repository"
+
+    def validate(self, data):
+        if "person" in data:
+            person = data["person"]
+            subscription = self.context.getSubscription(person)
+            if subscription is None and not self.context.userCanBeSubscribed(
+                person):
+                self.setFieldError(
+                    "person",
+                    "Open and delegated teams cannot be subscribed to "
+                    "private repositories.")
+
+    @action("Subscribe", name="subscribe_action")
+    def subscribe_action(self, action, data):
+        """Subscribe the specified user to the repository.
+
+        The user must be a member of a team in order to subscribe that team
+        to the repository.  Launchpad Admins are special and they can
+        subscribe any team.
+        """
+        notification_level = data["notification_level"]
+        max_diff_lines = self.optional_max_diff_lines(
+            notification_level, data["max_diff_lines"])
+        review_level = data["review_level"]
+        person = data["person"]
+        subscription = self.context.getSubscription(person)
+        if subscription is None:
+            self.context.subscribe(
+                person, notification_level, max_diff_lines, review_level,
+                self.user)
+            self.add_notification_message(
+                "%s has been subscribed to this repository with: "
+                % person.displayname, notification_level, max_diff_lines,
+                review_level)
+        else:
+            self.add_notification_message(
+                "%s was already subscribed to this repository with: "
+                % person.displayname,
+                subscription.notification_level, subscription.max_diff_lines,
+                review_level)
+
+
+class GitSubscriptionEditView(LaunchpadEditFormView):
+    """The view for editing repository subscriptions.
+
+    Used when traversed to the repository subscription itself rather than
+    through the repository action item to edit the user's own subscription.
+    This is the only current way to edit a team repository subscription.
+    """
+    schema = IGitSubscription
+    field_names = ["notification_level", "max_diff_lines", "review_level"]
+
+    @property
+    def page_title(self):
+        return (
+            "Edit subscription to repository %s" % self.repository.displayname)
+
+    @property
+    def label(self):
+        return (
+            "Edit subscription to repository for %s" % self.person.displayname)
+
+    def initialize(self):
+        self.repository = self.context.repository
+        self.person = self.context.person
+        super(GitSubscriptionEditView, self).initialize()
+
+    @action("Change", name="change")
+    def change_action(self, action, data):
+        """Update the repository subscription."""
+        self.updateContextFromData(data)
+
+    @action("Unsubscribe", name="unsubscribe")
+    def unsubscribe_action(self, action, data):
+        """Unsubscribe the team from the repository."""
+        self.repository.unsubscribe(self.person, self.user)
+        self.request.response.addNotification(
+            "%s has been unsubscribed from this repository."
+            % self.person.displayname)
+
+    @property
+    def next_url(self):
+        url = canonical_url(self.repository)
+        # If the subscriber can no longer see the repository, redirect them
+        # away.
+        service = getUtility(IService, "sharing")
+        _, _, repositories, _ = service.getVisibleArtifacts(
+            self.person, gitrepositories=[self.repository],
+            ignore_permissions=True)
+        if not repositories:
+            url = canonical_url(self.repository.target)
+        return url
+
+    cancel_url = next_url

=== added file 'lib/lp/code/browser/tests/test_gitsubscription.py'
--- lib/lp/code/browser/tests/test_gitsubscription.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/tests/test_gitsubscription.py	2015-04-21 10:09:18 +0000
@@ -0,0 +1,58 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Unit tests for GitSubscriptions."""
+
+__metaclass__ = type
+
+from lp.app.enums import InformationType
+from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
+from lp.services.features.testing import FeatureFixture
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.views import create_initialized_view
+
+
+class TestGitSubscriptionAddOtherView(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGitSubscriptionAddOtherView, self).setUp()
+        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+
+    def test_cannot_subscribe_open_team_to_private_repository(self):
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            information_type=InformationType.USERDATA, owner=owner)
+        team = self.factory.makeTeam()
+        form = {
+            'field.person': team.name,
+            'field.notification_level': 'NOEMAIL',
+            'field.max_diff_lines': 'NODIFF',
+            'field.review_level': 'NOEMAIL',
+            'field.actions.subscribe_action': 'Subscribe'}
+        with person_logged_in(owner):
+            view = create_initialized_view(
+                repository, '+addsubscriber', pricipal=owner, form=form)
+            self.assertContentEqual(
+                ['Open and delegated teams cannot be subscribed to private '
+                 'repositories.'], view.errors)
+
+    def test_can_subscribe_open_team_to_public_repository(self):
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=owner)
+        team = self.factory.makeTeam()
+        form = {
+            'field.person': team.name,
+            'field.notification_level': 'NOEMAIL',
+            'field.max_diff_lines': 'NODIFF',
+            'field.review_level': 'NOEMAIL',
+            'field.actions.subscribe_action': 'Subscribe'}
+        with person_logged_in(owner):
+            view = create_initialized_view(
+                repository, '+addsubscriber', pricipal=owner, form=form)
+            self.assertContentEqual([], view.errors)

=== modified file 'lib/lp/code/interfaces/gitsubscription.py'
--- lib/lp/code/interfaces/gitsubscription.py	2015-04-15 18:34:25 +0000
+++ lib/lp/code/interfaces/gitsubscription.py	2015-04-21 10:09:18 +0000
@@ -43,7 +43,7 @@
     export_as_webservice_entry(as_of="beta")
 
     id = Int(title=_("ID"), readonly=True, required=True)
-    personID = Int(title=_("Person ID"), required=True, readonly=True)
+    person_id = Int(title=_("Person ID"), required=True, readonly=True)
     person = exported(
         PersonChoice(
             title=_("Person"), required=True, vocabulary="ValidPersonOrTeam",

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2015-04-17 00:01:15 +0000
+++ lib/lp/code/model/gitrepository.py	2015-04-21 10:09:18 +0000
@@ -139,8 +139,9 @@
     This method is registered as a subscriber to `IObjectModifiedEvent`
     events on Git repositories.
     """
-    repository.date_last_modified = UTC_NOW
-    send_git_repository_modified_notifications(repository, event)
+    if event.edited_fields:
+        repository.date_last_modified = UTC_NOW
+        send_git_repository_modified_notifications(repository, event)
 
 
 class GitRepository(StormBase, GitIdentityMixin):

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2015-04-17 00:01:15 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2015-04-21 10:09:18 +0000
@@ -13,6 +13,7 @@
 from lazr.lifecycle.event import ObjectModifiedEvent
 import pytz
 from testtools.matchers import (
+    EndsWith,
     MatchesSetwise,
     MatchesStructure,
     )
@@ -26,7 +27,12 @@
     PUBLIC_INFORMATION_TYPES,
     )
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-from lp.code.enums import GitObjectType
+from lp.code.enums import (
+    BranchSubscriptionDiffSize,
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    GitObjectType,
+    )
 from lp.code.errors import (
     GitFeatureDisabled,
     GitRepositoryCreatorNotMemberOfOwnerTeam,
@@ -1423,3 +1429,107 @@
         self.assertEqual(401, response.status)
         with person_logged_in(ANONYMOUS):
             self.assertEqual(owner_db, repository_db.owner)
+
+    def test_subscribe(self):
+        # A user can subscribe to a repository.
+        repository_db = self.factory.makeGitRepository()
+        subscriber_db = self.factory.makePerson()
+        webservice = webservice_for_person(
+            subscriber_db, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        with person_logged_in(ANONYMOUS):
+            repository_url = api_url(repository_db)
+            subscriber_url = api_url(subscriber_db)
+        response = webservice.named_post(
+            repository_url, "subscribe", person=subscriber_url,
+            notification_level=u"Branch attribute notifications only",
+            max_diff_lines=u"Don't send diffs", code_review_level=u"No email")
+        self.assertEqual(200, response.status)
+        with person_logged_in(ANONYMOUS):
+            subscription_db = repository_db.getSubscription(subscriber_db)
+            self.assertIsNotNone(subscription_db)
+            self.assertThat(
+                response.jsonBody()["self_link"],
+                EndsWith(api_url(subscription_db)))
+
+    def _makeSubscription(self, repository, subscriber):
+        with person_logged_in(subscriber):
+            return repository.subscribe(
+                person=subscriber,
+                notification_level=(
+                    BranchSubscriptionNotificationLevel.ATTRIBUTEONLY),
+                max_diff_lines=BranchSubscriptionDiffSize.NODIFF,
+                code_review_level=CodeReviewNotificationLevel.NOEMAIL,
+                subscribed_by=subscriber)
+
+    def test_getSubscription(self):
+        # It is possible to get a single subscription via the webservice.
+        repository_db = self.factory.makeGitRepository()
+        subscriber_db = self.factory.makePerson()
+        subscription_db = self._makeSubscription(repository_db, subscriber_db)
+        with person_logged_in(subscriber_db):
+            repository_url = api_url(repository_db)
+            subscriber_url = api_url(subscriber_db)
+            subscription_url = api_url(subscription_db)
+        webservice = webservice_for_person(
+            subscriber_db, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_get(
+            repository_url, "getSubscription", person=subscriber_url)
+        self.assertEqual(200, response.status)
+        self.assertThat(
+            response.jsonBody()["self_link"], EndsWith(subscription_url))
+
+    def test_edit_subscription(self):
+        # An existing subscription can be edited via the webservice, by
+        # subscribing the same person again with different details.
+        repository_db = self.factory.makeGitRepository()
+        subscriber_db = self.factory.makePerson()
+        self._makeSubscription(repository_db, subscriber_db)
+        with person_logged_in(subscriber_db):
+            repository_url = api_url(repository_db)
+            subscriber_url = api_url(subscriber_db)
+        webservice = webservice_for_person(
+            subscriber_db, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            repository_url, "subscribe", person=subscriber_url,
+            notification_level=u"No email",
+            max_diff_lines=u"Send entire diff",
+            code_review_level=u"Status changes only")
+        self.assertEqual(200, response.status)
+        with person_logged_in(subscriber_db):
+            self.assertThat(
+                repository_db.getSubscription(subscriber_db),
+                MatchesStructure.byEquality(
+                    person=subscriber_db,
+                    notification_level=(
+                        BranchSubscriptionNotificationLevel.NOEMAIL),
+                    max_diff_lines=BranchSubscriptionDiffSize.WHOLEDIFF,
+                    review_level=CodeReviewNotificationLevel.STATUS,
+                    ))
+        repository = webservice.get(repository_url).jsonBody()
+        subscribers = webservice.get(
+            repository["subscribers_collection_link"]).jsonBody()
+        self.assertEqual(2, len(subscribers["entries"]))
+        with person_logged_in(subscriber_db):
+            self.assertContentEqual(
+                [repository_db.owner.name, subscriber_db.name],
+                [subscriber["name"] for subscriber in subscribers["entries"]])
+
+    def test_unsubscribe(self):
+        # It is possible to unsubscribe via the webservice.
+        repository_db = self.factory.makeGitRepository()
+        subscriber_db = self.factory.makePerson()
+        self._makeSubscription(repository_db, subscriber_db)
+        with person_logged_in(subscriber_db):
+            repository_url = api_url(repository_db)
+            subscriber_url = api_url(subscriber_db)
+        webservice = webservice_for_person(
+            subscriber_db, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            repository_url, "unsubscribe", person=subscriber_url)
+        self.assertEqual(200, response.status)
+        with person_logged_in(subscriber_db):
+            self.assertNotIn(subscriber_db, repository_db.subscribers)

=== added file 'lib/lp/code/templates/gitrepository-edit-subscription.pt'
--- lib/lp/code/templates/gitrepository-edit-subscription.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-edit-subscription.pt	2015-04-21 10:09:18 +0000
@@ -0,0 +1,45 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+
+  <body>
+
+<div metal:fill-slot="main">
+
+<tal:subscribed condition="view/user_is_subscribed">
+
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:extra fill-slot="extra_info">
+      <p class="documentDescription">
+        If you unsubscribe from a repository it will no longer show up on
+        your personal pages.
+      </p>
+    </metal:extra>
+  </div>
+
+</tal:subscribed>
+
+<tal:not_subscribed condition="not: view/user_is_subscribed">
+
+  <tal:comment condition="nothing">
+    This occurs if the user is hacking the URLs,
+    and should never be linked to from a valid page.
+
+    It could occur from a stale page as well if the user
+    is using a tabbed browser and hasn't refreshed the page.
+  </tal:comment>
+
+  <p class="documentDescription">
+    You are not currently subscribed to this repository.
+  </p>
+
+</tal:not_subscribed>
+
+</div>
+
+</body>
+</html>

=== modified file 'lib/lp/code/templates/gitrepository-index.pt'
--- lib/lp/code/templates/gitrepository-index.pt	2015-03-20 14:54:23 +0000
+++ lib/lp/code/templates/gitrepository-index.pt	2015-04-21 10:09:18 +0000
@@ -19,6 +19,7 @@
 
 <metal:side fill-slot="side">
   <div tal:replace="structure context/@@+global-actions" />
+  <tal:subscribers replace="structure context/@@+portlet-subscribers" />
 </metal:side>
 
 <tal:registering metal:fill-slot="registering">

=== added file 'lib/lp/code/templates/gitrepository-portlet-subscribers-content.pt'
--- lib/lp/code/templates/gitrepository-portlet-subscribers-content.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-portlet-subscribers-content.pt	2015-04-21 10:09:18 +0000
@@ -0,0 +1,31 @@
+<div
+  tal:omit-tag=""
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";>
+  <div class="section repository-subscribers">
+    <div
+      tal:condition="view/subscriptions"
+      tal:repeat="subscription view/subscriptions"
+      tal:attributes="id string:subscriber-${subscription/person/name}">
+        <a tal:condition="subscription/person/name|nothing"
+           tal:attributes="href subscription/person/fmt:url">
+
+          <tal:block replace="structure subscription/person/fmt:icon" />
+          <tal:block replace="subscription/person/fmt:displayname/fmt:shorten/20" />
+        </a>
+
+        <a tal:condition="subscription/required:launchpad.Edit"
+           tal:attributes="
+             href subscription/fmt:url;
+             title string:Edit subscription ${subscription/person/fmt:displayname};
+             id string:editsubscription-${subscription/person/name}">
+          <img class="editsub-icon" src="/@@/edit"
+            tal:attributes="id string:editsubscription-icon-${subscription/person/name}" />
+        </a>
+    </div>
+    <div id="none-subscribers" tal:condition="not:view/subscriptions">
+      No subscribers.
+    </div>
+  </div>
+</div>

=== added file 'lib/lp/code/templates/gitrepository-portlet-subscribers.pt'
--- lib/lp/code/templates/gitrepository-portlet-subscribers.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-portlet-subscribers.pt	2015-04-21 10:09:18 +0000
@@ -0,0 +1,29 @@
+<div
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  class="portlet" id="portlet-subscribers">
+  <div tal:define="context_menu view/context/menu:context">
+    <div>
+      <div class="section">
+        <div
+          tal:define="link context_menu/subscription"
+          tal:condition="link/enabled"
+          id="selfsubscriptioncontainer">
+          <a class="sprite add subscribe-self"
+             tal:attributes="href link/url"
+             tal:content="link/text" />
+        </div>
+        <div
+          tal:define="link context_menu/add_subscriber"
+          tal:condition="link/enabled"
+          tal:content="structure link/render" />
+      </div>
+    </div>
+
+    <h2>Subscribers</h2>
+    <div id="repository-subscribers-outer">
+      <div tal:replace="structure context/@@+repository-portlet-subscriber-content" />
+    </div>
+  </div>
+</div>

=== added file 'lib/lp/code/templates/gitsubscription-edit.pt'
--- lib/lp/code/templates/gitsubscription-edit.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitsubscription-edit.pt	2015-04-21 10:09:18 +0000
@@ -0,0 +1,25 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+>
+  <body>
+
+<div metal:fill-slot="main">
+
+  <div metal:use-macro="context/@@launchpad_form/form" >
+    <metal:extra fill-slot="extra_info">
+      <p class="documentDescription">
+        If you unsubscribe from a repository it will no longer show up on
+        your personal pages.
+      </p>
+    </metal:extra>
+  </div>
+
+</div>
+
+</body>
+</html>


Follow ups