← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Add Git repository subscriptions.

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/+merge/256381

Add Git repository subscriptions.  There are lots of tentacles into personmerge and sharing, mostly cleaning up the sites of previous XXX comments now that we can.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-subscriptions into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2015-04-13 19:02:15 +0000
+++ lib/lp/_schema_circular_imports.py	2015-04-15 18:39:22 +0000
@@ -71,6 +71,7 @@
 from lp.code.interfaces.diff import IPreviewDiff
 from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.interfaces.gitsubscription import IGitSubscription
 from lp.code.interfaces.hasbranches import (
     IHasBranches,
     IHasCodeImports,
@@ -567,6 +568,9 @@
 # IGitRepository
 patch_collection_property(IGitRepository, 'branches', IGitRef)
 patch_collection_property(IGitRepository, 'refs', IGitRef)
+patch_collection_property(IGitRepository, 'subscriptions', IGitSubscription)
+patch_entry_return_type(IGitRepository, 'subscribe', IGitSubscription)
+patch_entry_return_type(IGitRepository, 'getSubscription', IGitSubscription)
 
 # ILiveFSFile
 patch_reference_property(ILiveFSFile, 'livefsbuild', ILiveFSBuild)

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2015-03-12 15:21:27 +0000
+++ lib/lp/code/configure.zcml	2015-04-15 18:39:22 +0000
@@ -839,6 +839,15 @@
     <allow interface="lp.code.interfaces.gitrepository.IGitRepositorySet" />
   </securedutility>
 
+  <!-- GitSubscription -->
+
+  <class class="lp.code.model.gitsubscription.GitSubscription">
+    <allow interface="lp.code.interfaces.gitsubscription.IGitSubscription"/>
+    <require
+        permission="zope.Public"
+        set_schema="lp.code.interfaces.gitsubscription.IGitSubscription"/>
+  </class>
+
   <!-- GitNamespace -->
 
   <class class="lp.code.model.gitnamespace.PackageGitNamespace">

=== modified file 'lib/lp/code/interfaces/branchsubscription.py'
--- lib/lp/code/interfaces/branchsubscription.py	2013-02-26 03:20:44 +0000
+++ lib/lp/code/interfaces/branchsubscription.py	2015-04-15 18:39:22 +0000
@@ -1,7 +1,7 @@
 # Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Bug subscription interfaces."""
+"""Bazaar branch subscription interfaces."""
 
 __metaclass__ = type
 

=== modified file 'lib/lp/code/interfaces/gitcollection.py'
--- lib/lp/code/interfaces/gitcollection.py	2015-02-23 15:58:36 +0000
+++ lib/lp/code/interfaces/gitcollection.py	2015-04-15 18:39:22 +0000
@@ -113,6 +113,10 @@
         :return: A `ResultSet` of repositories that matched.
         """
 
+    def subscribedBy(person):
+        """Restrict the collection to repositories subscribed to by
+        'person'."""
+
     def visibleByUser(person):
         """Restrict the collection to repositories that person is allowed to
         see."""

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2015-04-13 19:02:15 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2015-04-15 18:39:22 +0000
@@ -55,6 +55,11 @@
 from lp import _
 from lp.app.enums import InformationType
 from lp.app.validators import LaunchpadValidationError
+from lp.code.enums import (
+    BranchSubscriptionDiffSize,
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    )
 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
 from lp.registry.interfaces.distributionsourcepackage import (
@@ -201,6 +206,16 @@
         # Really IGitRef, patched in _schema_circular_imports.py.
         value_type=Reference(Interface)))
 
+    subscriptions = exported(CollectionField(
+        title=_("GitSubscriptions associated with this repository."),
+        readonly=True,
+        # Really IGitSubscription, patched in _schema_circular_imports.py.
+        value_type=Reference(Interface)))
+
+    subscribers = exported(CollectionField(
+        title=_("Persons subscribed to this repository."),
+        readonly=True, value_type=Reference(IPerson)))
+
     def getRefByPath(path):
         """Look up a single reference in this repository by path.
 
@@ -335,6 +350,73 @@
               where the context object is the repository itself.
         """
 
+    def userCanBeSubscribed(person):
+        """Return True if the `IPerson` can be subscribed to the repository."""
+
+    @operation_parameters(
+        person=Reference(title=_("The person to subscribe."), schema=IPerson),
+        notification_level=Choice(
+            title=_("The level of notification to subscribe to."),
+            vocabulary=BranchSubscriptionNotificationLevel),
+        max_diff_lines=Choice(
+            title=_("The max number of lines for diff email."),
+            vocabulary=BranchSubscriptionDiffSize),
+        code_review_level=Choice(
+            title=_("The level of code review notification emails."),
+            vocabulary=CodeReviewNotificationLevel))
+    # Really IGitSubscription, patched in _schema_circular_imports.py.
+    @operation_returns_entry(Interface)
+    @call_with(subscribed_by=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version("devel")
+    def subscribe(person, notification_level, max_diff_lines,
+                  code_review_level, subscribed_by):
+        """Subscribe this person to the repository.
+
+        :param person: The `Person` to subscribe.
+        :param notification_level: The kinds of repository changes that
+            cause notification.
+        :param max_diff_lines: The maximum number of lines of diff that may
+            appear in a notification.
+        :param code_review_level: The kinds of code review activity that
+            cause notification.
+        :param subscribed_by: The person who is subscribing the subscriber.
+            Most often the subscriber themselves.
+        :return: A new or existing `GitSubscription`.
+        """
+
+    @operation_parameters(
+        person=Reference(title=_("The person to unsubscribe"), schema=IPerson))
+    # Really IGitSubscription, patched in _schema_circular_imports.py.
+    @operation_returns_entry(Interface)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getSubscription(person):
+        """Return the `GitSubscription` for this person."""
+
+    def hasSubscription(person):
+        """Is this person subscribed to the repository?"""
+
+    @operation_parameters(
+        person=Reference(title=_("The person to unsubscribe"), schema=IPerson))
+    @call_with(unsubscribed_by=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version("devel")
+    def unsubscribe(person, unsubscribed_by):
+        """Remove the person's subscription to this repository.
+
+        :param person: The person or team to unsubscribe from the repository.
+        :param unsubscribed_by: The person doing the unsubscribing.
+        """
+
+    def getSubscriptionsByLevel(notification_levels):
+        """Return the subscriptions that are at the given notification levels.
+
+        :param notification_levels: An iterable of
+            `BranchSubscriptionNotificationLevel`s.
+        :return: A `ResultSet`.
+        """
+
 
 class IGitRepositoryModerateAttributes(Interface):
     """IGitRepository attributes that can be edited by more than one community.

=== added file 'lib/lp/code/interfaces/gitsubscription.py'
--- lib/lp/code/interfaces/gitsubscription.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitsubscription.py	2015-04-15 18:39:22 +0000
@@ -0,0 +1,99 @@
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository subscription interfaces."""
+
+__metaclass__ = type
+
+__all__ = [
+    'IGitSubscription',
+    ]
+
+from lazr.restful.declarations import (
+    call_with,
+    export_as_webservice_entry,
+    export_read_operation,
+    exported,
+    operation_for_version,
+    REQUEST_USER,
+    )
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Choice,
+    Int,
+    )
+
+from lp import _
+from lp.code.enums import (
+    BranchSubscriptionDiffSize,
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    )
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.fields import PersonChoice
+
+
+class IGitSubscription(Interface):
+    """The relationship between a person and a Git repository."""
+
+    # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    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 = exported(
+        PersonChoice(
+            title=_("Person"), required=True, vocabulary="ValidPersonOrTeam",
+            readonly=True,
+            description=_(
+                'Enter the launchpad id, or email address of the person you '
+                'wish to subscribe to this repository. If you are unsure, use '
+                'the "Choose..." option to find the person in Launchpad. You '
+                'can only subscribe someone who is a registered user of the '
+                'system.')))
+    repository = exported(
+        Reference(
+            title=_("Repository ID"), required=True, readonly=True,
+            schema=IGitRepository))
+    notification_level = exported(
+        Choice(
+            title=_("Notification Level"), required=True,
+            vocabulary=BranchSubscriptionNotificationLevel,
+            default=BranchSubscriptionNotificationLevel.ATTRIBUTEONLY,
+            description=_(
+                "Attribute notifications are sent when repository details are "
+                "changed such as lifecycle status and name.  Revision "
+                "notifications are generated when new revisions are found.")))
+    max_diff_lines = exported(
+        Choice(
+            title=_("Generated Diff Size Limit"), required=True,
+            vocabulary=BranchSubscriptionDiffSize,
+            default=BranchSubscriptionDiffSize.ONEKLINES,
+            description=_(
+                "Diffs greater than the specified number of lines will not "
+                "be sent to the subscriber.  The subscriber will still "
+                "receive an email with the new revision details even if the "
+                "diff is larger than the specified number of lines.")))
+    review_level = exported(
+        Choice(
+            title=_("Code review Level"), required=True,
+            vocabulary=CodeReviewNotificationLevel,
+            default=CodeReviewNotificationLevel.FULL,
+            description=_(
+                "Control the kind of review activity that triggers "
+                "notifications."
+                )))
+
+    subscribed_by = exported(PersonChoice(
+        title=_("Subscribed by"), required=True,
+        vocabulary="ValidPersonOrTeam", readonly=True,
+        description=_("The person who created this subscription.")))
+
+    @call_with(user=REQUEST_USER)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def canBeUnsubscribedByUser(user):
+        """Can the user unsubscribe the subscriber from the repository?"""

=== modified file 'lib/lp/code/interfaces/webservice.py'
--- lib/lp/code/interfaces/webservice.py	2015-04-13 19:02:15 +0000
+++ lib/lp/code/interfaces/webservice.py	2015-04-15 18:39:22 +0000
@@ -28,6 +28,7 @@
     'IGitRef',
     'IGitRepository',
     'IGitRepositorySet',
+    'IGitSubscription',
     'IHasGitRepositories',
     'IPreviewDiff',
     'ISourcePackageRecipe',
@@ -66,6 +67,7 @@
     IGitRepository,
     IGitRepositorySet,
     )
+from lp.code.interfaces.gitsubscription import IGitSubscription
 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
 from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
 from lp.code.interfaces.sourcepackagerecipebuild import (

=== modified file 'lib/lp/code/model/branchnamespace.py'
--- lib/lp/code/model/branchnamespace.py	2015-02-11 11:48:49 +0000
+++ lib/lp/code/model/branchnamespace.py	2015-04-15 18:39:22 +0000
@@ -158,10 +158,10 @@
             control_format=control_format, distroseries=distroseries,
             sourcepackagename=sourcepackagename)
 
-        # The registrant of the branch should also be automatically subscribed
-        # in order for them to get code review notifications.  The implicit
-        # registrant subscription does not cause email to be sent about
-        # attribute changes, just merge proposals and code review comments.
+        # The owner of the branch should also be automatically subscribed in
+        # order for them to get code review notifications.  The implicit
+        # owner subscription does not cause email to be sent about attribute
+        # changes, just merge proposals and code review comments.
         branch.subscribe(
             self.owner,
             BranchSubscriptionNotificationLevel.NOEMAIL,

=== modified file 'lib/lp/code/model/gitcollection.py'
--- lib/lp/code/model/gitcollection.py	2015-02-23 15:58:36 +0000
+++ lib/lp/code/model/gitcollection.py	2015-04-15 18:39:22 +0000
@@ -35,6 +35,7 @@
     GitRepository,
     get_git_repository_privacy_filter,
     )
+from lp.code.model.gitsubscription import GitSubscription
 from lp.registry.enums import EXCLUSIVE_TEAM_POLICY
 from lp.registry.model.person import Person
 from lp.registry.model.product import Product
@@ -267,6 +268,14 @@
         return collection.getRepositories(eager_load=False).order_by(
             GitRepository.name, GitRepository.id)
 
+    def subscribedBy(self, person):
+        """See `IGitCollection`."""
+        return self._filterBy(
+            [GitSubscription.person == person],
+            table=GitSubscription,
+            join=Join(GitSubscription,
+                      GitSubscription.repository == GitRepository.id))
+
     def visibleByUser(self, person):
         """See `IGitCollection`."""
         if (person == LAUNCHPAD_SERVICES or

=== modified file 'lib/lp/code/model/gitnamespace.py'
--- lib/lp/code/model/gitnamespace.py	2015-03-17 16:05:54 +0000
+++ lib/lp/code/model/gitnamespace.py	2015-04-15 18:39:22 +0000
@@ -25,6 +25,11 @@
     PUBLIC_INFORMATION_TYPES,
     )
 from lp.app.interfaces.services import IService
+from lp.code.enums import (
+    BranchSubscriptionDiffSize,
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    )
 from lp.code.errors import (
     GitRepositoryCreationForbidden,
     GitRepositoryCreatorNotMemberOfOwnerTeam,
@@ -74,6 +79,18 @@
         repository = GitRepository(
             registrant, self.owner, self.target, name, information_type,
             date_created, description=description)
+
+        # The owner of the repository should also be automatically subscribed
+        # in order for them to get code review notifications.  The implicit
+        # owner subscription does not cause email to be sent about attribute
+        # changes, just merge proposals and code review comments.
+        repository.subscribe(
+            self.owner,
+            BranchSubscriptionNotificationLevel.NOEMAIL,
+            BranchSubscriptionDiffSize.NODIFF,
+            CodeReviewNotificationLevel.FULL,
+            registrant)
+
         repository._reconcileAccess()
 
         notify(ObjectCreatedEvent(repository))

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2015-03-30 09:46:03 +0000
+++ lib/lp/code/model/gitrepository.py	2015-04-15 18:39:22 +0000
@@ -45,6 +45,10 @@
     PRIVATE_INFORMATION_TYPES,
     PUBLIC_INFORMATION_TYPES,
     )
+from lp.app.errors import (
+    SubscriptionPrivacyViolation,
+    UserCannotUnsubscribePerson,
+    )
 from lp.app.interfaces.informationtype import IInformationType
 from lp.app.interfaces.launchpad import IPrivacy
 from lp.app.interfaces.services import IService
@@ -72,9 +76,11 @@
     )
 from lp.code.interfaces.revision import IRevisionSet
 from lp.code.model.gitref import GitRef
+from lp.code.model.gitsubscription import GitSubscription
 from lp.registry.enums import PersonVisibility
 from lp.registry.errors import CannotChangeInformationType
 from lp.registry.interfaces.accesspolicy import (
+    IAccessArtifactGrantSource,
     IAccessArtifactSource,
     IAccessPolicySource,
     )
@@ -91,6 +97,7 @@
     AccessPolicyGrant,
     reconcile_access_for_artifact,
     )
+from lp.registry.model.person import Person
 from lp.registry.model.teammembership import TeamParticipation
 from lp.services.config import config
 from lp.services.database import bulk
@@ -560,15 +567,11 @@
             raise CannotChangeInformationType("Forbidden by project policy.")
         self.information_type = information_type
         self._reconcileAccess()
-        # XXX cjwatson 2015-02-05: Once we have repository subscribers, we
-        # need to grant them access if necessary.  For now, treat the owner
-        # as always subscribed, which is just about enough to make the
-        # GitCollection tests pass.
-        if information_type in PRIVATE_INFORMATION_TYPES:
+        if information_type in PRIVATE_INFORMATION_TYPES and self.subscribers:
             # Grant the subscriber access if they can't see the repository.
             service = getUtility(IService, "sharing")
             blind_subscribers = service.getPeopleWithoutAccess(
-                self, [self.owner])
+                self, self.subscribers)
             if len(blind_subscribers):
                 service.ensureAccessGrants(
                     blind_subscribers, user, gitrepositories=[self],
@@ -583,6 +586,101 @@
         new_namespace = get_git_namespace(self.target, new_owner)
         new_namespace.moveRepository(self, user, rename_if_necessary=True)
 
+    @property
+    def subscriptions(self):
+        return Store.of(self).find(
+            GitSubscription,
+            GitSubscription.repository == self)
+
+    @property
+    def subscribers(self):
+        return Store.of(self).find(
+            Person,
+            GitSubscription.person_id == Person.id,
+            GitSubscription.repository == self)
+
+    def userCanBeSubscribed(self, person):
+        """See `IGitRepository`."""
+        return not (
+            person.is_team and
+            self.information_type in PRIVATE_INFORMATION_TYPES and
+            person.anyone_can_join())
+
+    def subscribe(self, person, notification_level, max_diff_lines,
+                  code_review_level, subscribed_by):
+        """See `IGitRepository`."""
+        if not self.userCanBeSubscribed(person):
+            raise SubscriptionPrivacyViolation(
+                "Open and delegated teams cannot be subscribed to private "
+                "repositories.")
+        # If the person is already subscribed, update the subscription with
+        # the specified notification details.
+        subscription = self.getSubscription(person)
+        if subscription is None:
+            subscription = GitSubscription(
+                person=person, repository=self,
+                notification_level=notification_level,
+                max_diff_lines=max_diff_lines, review_level=code_review_level,
+                subscribed_by=subscribed_by)
+            Store.of(subscription).flush()
+        else:
+            subscription.notification_level = notification_level
+            subscription.max_diff_lines = max_diff_lines
+            subscription.review_level = code_review_level
+        # Grant the subscriber access if they can't see the repository.
+        service = getUtility(IService, "sharing")
+        _, _, repositories, _ = service.getVisibleArtifacts(
+            person, gitrepositories=[self], ignore_permissions=True)
+        if not repositories:
+            service.ensureAccessGrants(
+                [person], subscribed_by, gitrepositories=[self],
+                ignore_permissions=True)
+        return subscription
+
+    def getSubscription(self, person):
+        """See `IGitRepository`."""
+        if person is None:
+            return None
+        return Store.of(self).find(
+            GitSubscription,
+            GitSubscription.person == person,
+            GitSubscription.repository == self).one()
+
+    def getSubscriptionsByLevel(self, notification_levels):
+        """See `IGitRepository`."""
+        # XXX: JonathanLange 2009-05-07 bug=373026: This is only used by real
+        # code to determine whether there are any subscribers at the given
+        # notification levels. The only code that cares about the actual
+        # object is in a test:
+        # test_only_nodiff_subscribers_means_no_diff_generated.
+        return Store.of(self).find(
+            GitSubscription,
+            GitSubscription.repository == self,
+            GitSubscription.notification_level.is_in(notification_levels))
+
+    def hasSubscription(self, person):
+        """See `IGitRepository`."""
+        return self.getSubscription(person) is not None
+
+    def unsubscribe(self, person, unsubscribed_by, ignore_permissions=False):
+        """See `IGitRepository`."""
+        subscription = self.getSubscription(person)
+        if subscription is None:
+            # Silent success seems order of the day (like bugs).
+            return
+        if (not ignore_permissions
+            and not subscription.canBeUnsubscribedByUser(unsubscribed_by)):
+            raise UserCannotUnsubscribePerson(
+                '%s does not have permission to unsubscribe %s.' % (
+                    unsubscribed_by.displayname,
+                    person.displayname))
+        store = Store.of(subscription)
+        store.remove(subscription)
+        artifact = getUtility(IAccessArtifactSource).find([self])
+        getUtility(IAccessArtifactGrantSource).revokeByArtifact(
+            artifact, [person])
+        store.flush()
+
     def destroySelf(self):
         raise NotImplementedError
 

=== added file 'lib/lp/code/model/gitsubscription.py'
--- lib/lp/code/model/gitsubscription.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitsubscription.py	2015-04-15 18:39:22 +0000
@@ -0,0 +1,71 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'GitSubscription',
+    ]
+
+from storm.locals import (
+    Int,
+    Reference,
+    )
+from zope.interface import implements
+
+from lp.code.enums import (
+    BranchSubscriptionDiffSize,
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    )
+from lp.code.interfaces.gitsubscription import IGitSubscription
+from lp.code.security import GitSubscriptionEdit
+from lp.registry.interfaces.person import validate_person
+from lp.registry.interfaces.role import IPersonRoles
+from lp.services.database.constants import DEFAULT
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.stormbase import StormBase
+
+
+class GitSubscription(StormBase):
+    """A relationship between a person and a Git repository."""
+
+    __storm_table__ = 'GitSubscription'
+
+    implements(IGitSubscription)
+
+    id = Int(primary=True)
+
+    person_id = Int(name='person', allow_none=False, validator=validate_person)
+    person = Reference(person_id, 'Person.id')
+
+    repository_id = Int(name='repository', allow_none=False)
+    repository = Reference(repository_id, 'GitRepository.id')
+
+    notification_level = EnumCol(
+        enum=BranchSubscriptionNotificationLevel, notNull=True,
+        default=DEFAULT)
+    max_diff_lines = EnumCol(
+        enum=BranchSubscriptionDiffSize, notNull=False, default=DEFAULT)
+    review_level = EnumCol(
+        enum=CodeReviewNotificationLevel, notNull=True, default=DEFAULT)
+
+    subscribed_by_id = Int(
+        name='subscribed_by', allow_none=False, validator=validate_person)
+    subscribed_by = Reference(subscribed_by_id, 'Person.id')
+
+    def __init__(self, person, repository, notification_level, max_diff_lines,
+                 review_level, subscribed_by):
+        super(GitSubscription, self).__init__()
+        self.person = person
+        self.repository = repository
+        self.notification_level = notification_level
+        self.max_diff_lines = max_diff_lines
+        self.review_level = review_level
+        self.subscribed_by = subscribed_by
+
+    def canBeUnsubscribedByUser(self, user):
+        """See `IBranchSubscription`."""
+        if user is None:
+            return False
+        permission_check = GitSubscriptionEdit(self)
+        return permission_check.checkAuthenticated(IPersonRoles(user))

=== modified file 'lib/lp/code/model/tests/test_branchsubscription.py'
--- lib/lp/code/model/tests/test_branchsubscription.py	2015-02-16 13:01:34 +0000
+++ lib/lp/code/model/tests/test_branchsubscription.py	2015-04-15 18:39:22 +0000
@@ -1,7 +1,7 @@
 # Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Tests for the BranchSubscrptions model object.."""
+"""Tests for the BranchSubscription model object."""
 
 __metaclass__ = type
 

=== modified file 'lib/lp/code/model/tests/test_gitcollection.py'
--- lib/lp/code/model/tests/test_gitcollection.py	2015-03-05 14:13:16 +0000
+++ lib/lp/code/model/tests/test_gitcollection.py	2015-04-15 18:39:22 +0000
@@ -12,6 +12,11 @@
 from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.app.interfaces.services import IService
+from lp.code.enums import (
+    BranchSubscriptionDiffSize,
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    )
 from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES
 from lp.code.interfaces.gitcollection import (
     IAllGitRepositories,
@@ -388,6 +393,19 @@
         collection = self.all_repositories.registeredBy(registrant)
         self.assertEqual([repository], list(collection.getRepositories()))
 
+    def test_subscribedBy(self):
+        # 'subscribedBy' returns a new collection that only has repositories
+        # that the given user is subscribed to.
+        repository = self.factory.makeGitRepository()
+        subscriber = self.factory.makePerson()
+        repository.subscribe(
+            subscriber, BranchSubscriptionNotificationLevel.NOEMAIL,
+            BranchSubscriptionDiffSize.NODIFF,
+            CodeReviewNotificationLevel.NOEMAIL,
+            subscriber)
+        collection = self.all_repositories.subscribedBy(subscriber)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
 
 class TestGenericGitCollectionVisibleFilter(TestCaseWithFactory):
 
@@ -457,6 +475,40 @@
             sorted(self.all_repositories.getRepositories()),
             sorted(repositories.getRepositories()))
 
+    def test_subscribers_can_see_repositories(self):
+        # A person subscribed to a repository can see it, even if it's
+        # private.
+        subscriber = self.factory.makePerson()
+        removeSecurityProxy(self.private_repository).subscribe(
+            subscriber, BranchSubscriptionNotificationLevel.NOEMAIL,
+            BranchSubscriptionDiffSize.NODIFF,
+            CodeReviewNotificationLevel.NOEMAIL,
+            subscriber)
+        repositories = self.all_repositories.visibleByUser(subscriber)
+        self.assertEqual(
+            sorted([self.public_repository, self.private_repository]),
+            sorted(repositories.getRepositories()))
+
+    def test_subscribed_team_members_can_see_repositories(self):
+        # A person in a team that is subscribed to a repository can see that
+        # repository, even if it's private.
+        team_owner = self.factory.makePerson()
+        team = self.factory.makeTeam(
+            membership_policy=TeamMembershipPolicy.MODERATED,
+            owner=team_owner)
+        # Subscribe the team.
+        removeSecurityProxy(self.private_repository).subscribe(
+            team, BranchSubscriptionNotificationLevel.NOEMAIL,
+            BranchSubscriptionDiffSize.NODIFF,
+            CodeReviewNotificationLevel.NOEMAIL,
+            team_owner)
+        # Members of the team can see the private repository that the team
+        # is subscribed to.
+        repositories = self.all_repositories.visibleByUser(team_owner)
+        self.assertEqual(
+            sorted([self.public_repository, self.private_repository]),
+            sorted(repositories.getRepositories()))
+
     def test_private_teams_see_own_private_personal_repositories(self):
         # Private teams are given an access grant to see their private
         # personal repositories.
@@ -473,9 +525,7 @@
             # they are the owner.  We want to unsubscribe them so that they
             # lose access conferred via subscription and rely instead on the
             # APG.
-            # XXX cjwatson 2015-02-05: Uncomment this once
-            # GitRepositorySubscriptions exist.
-            #personal_repository.unsubscribe(team, team_owner, True)
+            personal_repository.unsubscribe(team, team_owner, True)
             # Make another personal repository the team can't see.
             other_person = self.factory.makePerson()
             self.factory.makeGitRepository(

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2015-03-20 14:54:23 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2015-04-15 18:39:22 +0000
@@ -970,7 +970,7 @@
         owner = self.factory.makeTeam(visibility=PersonVisibility.PRIVATE)
         with person_logged_in(owner):
             repository = self.factory.makeGitRepository(
-                owner=owner, target=owner,
+                owner=owner,
                 information_type=InformationType.USERDATA)
             repository.setTarget(target=owner, user=owner)
         self.assertEqual(

=== added file 'lib/lp/code/model/tests/test_gitsubscription.py'
--- lib/lp/code/model/tests/test_gitsubscription.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/tests/test_gitsubscription.py	2015-04-15 18:39:22 +0000
@@ -0,0 +1,201 @@
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the GitSubscription model object."""
+
+__metaclass__ = type
+
+
+from lp.app.enums import InformationType
+from lp.app.errors import (
+    SubscriptionPrivacyViolation,
+    UserCannotUnsubscribePerson,
+    )
+from lp.code.enums import (
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    )
+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
+
+
+class TestGitSubscriptions(TestCaseWithFactory):
+    """Tests relating to Git repository subscriptions in general."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGitSubscriptions, self).setUp()
+        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+
+    def test_owner_subscribed(self):
+        # The owner of a repository is subscribed to the repository.
+        repository = self.factory.makeGitRepository()
+        [subscription] = list(repository.subscriptions)
+        self.assertEqual(repository.owner, subscription.person)
+
+    def test_subscribed_by_set(self):
+        # The user subscribing is recorded along with the subscriber.
+        subscriber = self.factory.makePerson()
+        subscribed_by = self.factory.makePerson()
+        repository = self.factory.makeGitRepository()
+        subscription = repository.subscribe(
+            subscriber, BranchSubscriptionNotificationLevel.NOEMAIL, None,
+            CodeReviewNotificationLevel.NOEMAIL, subscribed_by)
+        self.assertEqual(subscriber, subscription.person)
+        self.assertEqual(subscribed_by, subscription.subscribed_by)
+
+    def test_unsubscribe(self):
+        # Test unsubscribing by the subscriber.
+        subscription = self.factory.makeGitSubscription()
+        subscriber = subscription.person
+        repository = subscription.repository
+        repository.unsubscribe(subscriber, subscriber)
+        self.assertFalse(repository.hasSubscription(subscriber))
+
+    def test_unsubscribe_by_subscriber(self):
+        # Test unsubscribing by the person who subscribed the user.
+        subscribed_by = self.factory.makePerson()
+        subscription = self.factory.makeGitSubscription(
+            subscribed_by=subscribed_by)
+        subscriber = subscription.person
+        repository = subscription.repository
+        repository.unsubscribe(subscriber, subscribed_by)
+        self.assertFalse(repository.hasSubscription(subscriber))
+
+    def test_unsubscribe_by_unauthorized(self):
+        # Test unsubscribing someone you shouldn't be able to.
+        subscription = self.factory.makeGitSubscription()
+        repository = subscription.repository
+        self.assertRaises(
+            UserCannotUnsubscribePerson,
+            repository.unsubscribe,
+            subscription.person,
+            self.factory.makePerson())
+
+    def test_cannot_subscribe_open_team_to_private_repository(self):
+        # It is forbidden to subscribe a open team to a private repository.
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            information_type=InformationType.USERDATA, owner=owner)
+        team = self.factory.makeTeam()
+        with person_logged_in(owner):
+            self.assertRaises(
+                SubscriptionPrivacyViolation, repository.subscribe, team, None,
+                None, None, owner)
+
+    def test_subscribe_gives_access(self):
+        # Subscribing a user to a repository gives them access.
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            information_type=InformationType.USERDATA, owner=owner)
+        subscribee = self.factory.makePerson()
+        with person_logged_in(owner):
+            self.assertFalse(repository.visibleByUser(subscribee))
+            repository.subscribe(
+                subscribee, BranchSubscriptionNotificationLevel.NOEMAIL,
+                None, CodeReviewNotificationLevel.NOEMAIL, owner)
+            self.assertTrue(repository.visibleByUser(subscribee))
+
+    def test_unsubscribe_removes_access(self):
+        # Unsubscribing a user from a repository removes their access.
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            information_type=InformationType.USERDATA, owner=owner)
+        subscribee = self.factory.makePerson()
+        with person_logged_in(owner):
+            repository.subscribe(
+                subscribee, BranchSubscriptionNotificationLevel.NOEMAIL,
+                None, CodeReviewNotificationLevel.NOEMAIL, owner)
+            self.assertTrue(repository.visibleByUser(subscribee))
+            repository.unsubscribe(subscribee, owner)
+            self.assertFalse(repository.visibleByUser(subscribee))
+
+
+class TestGitSubscriptionCanBeUnsubscribedbyUser(TestCaseWithFactory):
+    """Tests for GitSubscription.canBeUnsubscribedByUser."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGitSubscriptionCanBeUnsubscribedbyUser, self).setUp()
+        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+
+    def test_none(self):
+        # None for a user always returns False.
+        subscription = self.factory.makeGitSubscription()
+        self.assertFalse(subscription.canBeUnsubscribedByUser(None))
+
+    def test_self_subscriber(self):
+        # The subscriber has permission to unsubscribe.
+        subscription = self.factory.makeGitSubscription()
+        self.assertTrue(
+            subscription.canBeUnsubscribedByUser(subscription.person))
+
+    def test_non_subscriber_fails(self):
+        # An unrelated person can't unsubscribe a user.
+        subscription = self.factory.makeGitSubscription()
+        editor = self.factory.makePerson()
+        self.assertFalse(subscription.canBeUnsubscribedByUser(editor))
+
+    def test_subscribed_by(self):
+        # If a user subscribes someone else, the user can unsubscribe.
+        subscribed_by = self.factory.makePerson()
+        subscriber = self.factory.makePerson()
+        subscription = self.factory.makeGitSubscription(
+            person=subscriber, subscribed_by=subscribed_by)
+        self.assertTrue(subscription.canBeUnsubscribedByUser(subscribed_by))
+
+    def test_team_member_can_unsubscribe(self):
+        # Any team member can unsubscribe the team from a repository.
+        team = self.factory.makeTeam()
+        member = self.factory.makePerson()
+        with person_logged_in(team.teamowner):
+            team.addMember(member, team.teamowner)
+        subscription = self.factory.makeGitSubscription(
+            person=team, subscribed_by=team.teamowner)
+        self.assertTrue(subscription.canBeUnsubscribedByUser(member))
+
+    def test_team_subscriber_can_unsubscribe(self):
+        # A team can be unsubscribed by the subscriber even if they are not
+        # a member.
+        team = self.factory.makeTeam()
+        subscribed_by = self.factory.makePerson()
+        subscription = self.factory.makeGitSubscription(
+            person=team, subscribed_by=subscribed_by)
+        self.assertTrue(subscription.canBeUnsubscribedByUser(subscribed_by))
+
+    def test_repository_person_owner_can_unsubscribe(self):
+        # The repository owner can unsubscribe someone from the repository.
+        repository_owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=repository_owner)
+        subscribed_by = self.factory.makePerson()
+        subscriber = self.factory.makePerson()
+        subscription = self.factory.makeGitSubscription(
+            repository=repository, person=subscriber,
+            subscribed_by=subscribed_by)
+        self.assertTrue(subscription.canBeUnsubscribedByUser(repository_owner))
+
+    def test_repository_team_owner_can_unsubscribe(self):
+        # The repository team owner can unsubscribe someone from the
+        # repository.
+        #
+        # If the owner of a repository is a team, then the team members can
+        # unsubscribe someone.
+        team_owner = self.factory.makePerson()
+        team_member = self.factory.makePerson()
+        repository_owner = self.factory.makeTeam(
+            owner=team_owner, members=[team_member])
+        repository = self.factory.makeGitRepository(owner=repository_owner)
+        subscribed_by = self.factory.makePerson()
+        subscriber = self.factory.makePerson()
+        subscription = self.factory.makeGitSubscription(
+            repository=repository, person=subscriber,
+            subscribed_by=subscribed_by)
+        self.assertTrue(subscription.canBeUnsubscribedByUser(team_owner))
+        self.assertTrue(subscription.canBeUnsubscribedByUser(team_member))

=== modified file 'lib/lp/code/security.py'
--- lib/lp/code/security.py	2011-07-13 06:06:53 +0000
+++ lib/lp/code/security.py	2015-04-15 18:39:22 +0000
@@ -7,10 +7,13 @@
 __all__ = [
     'BranchSubscriptionEdit',
     'BranchSubscriptionView',
+    'GitSubscriptionEdit',
+    'GitSubscriptionView',
     ]
 
 from lp.app.security import AuthorizationBase
 from lp.code.interfaces.branchsubscription import IBranchSubscription
+from lp.code.interfaces.gitsubscription import IGitSubscription
 
 
 class BranchSubscriptionEdit(AuthorizationBase):
@@ -34,3 +37,27 @@
 
 class BranchSubscriptionView(BranchSubscriptionEdit):
     permission = 'launchpad.View'
+
+
+class GitSubscriptionEdit(AuthorizationBase):
+    permission = 'launchpad.Edit'
+    usedfor = IGitSubscription
+
+    def checkAuthenticated(self, user):
+        """Is the user able to edit a Git repository subscription?
+
+        Any team member can edit a Git repository subscription for their
+        team.
+        Launchpad Admins can also edit any Git repository subscription.
+        The owner of the subscribed repository can edit the subscription. If
+        the repository owner is a team, then members of the team can edit
+        the subscription.
+        """
+        return (user.inTeam(self.obj.repository.owner) or
+                user.inTeam(self.obj.person) or
+                user.inTeam(self.obj.subscribed_by) or
+                user.in_admin)
+
+
+class GitSubscriptionView(GitSubscriptionEdit):
+    permission = 'launchpad.View'

=== modified file 'lib/lp/registry/browser/tests/private-team-creation-views.txt'
--- lib/lp/registry/browser/tests/private-team-creation-views.txt	2014-01-08 07:33:25 +0000
+++ lib/lp/registry/browser/tests/private-team-creation-views.txt	2015-04-15 18:39:22 +0000
@@ -230,14 +230,14 @@
 Public teams can be made private if the only artifacts they have are
 those permitted by private teams.
 
+    >>> from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
+    >>> from lp.services.features.testing import FeatureFixture
     >>> def createTeamArtifacts(team, team_owner):
     ...     # A bug subscription.
     ...     bug = factory.makeBug()
     ...     bug.subscribe(team, team_owner)
     ...     bugtask = bug.default_bugtask
     ...     bugtask.transitionToAssignee(team)
-    ...     # A branch.
-    ...     branch = factory.makeBranch(owner=team, registrant=team_owner)
     ...     # A branch subscription.
     ...     from lp.code.enums import (
     ...         BranchSubscriptionDiffSize,
@@ -249,6 +249,14 @@
     ...         BranchSubscriptionNotificationLevel.DIFFSONLY,
     ...         BranchSubscriptionDiffSize.WHOLEDIFF,
     ...         CodeReviewNotificationLevel.STATUS, team_owner)
+    ...     # A Git repository subscription.
+    ...     with FeatureFixture({GIT_FEATURE_FLAG: 'on'}):
+    ...         repository = factory.makeGitRepository()
+    ...     repository.subscribe(
+    ...         team,
+    ...         BranchSubscriptionNotificationLevel.DIFFSONLY,
+    ...         BranchSubscriptionDiffSize.WHOLEDIFF,
+    ...         CodeReviewNotificationLevel.STATUS, team_owner)
     ...     # A PPA.
     ...     from lp.soyuz.enums import ArchivePurpose
     ...     from lp.soyuz.interfaces.archive import IArchiveSet

=== modified file 'lib/lp/registry/doc/private-team-roles.txt'
--- lib/lp/registry/doc/private-team-roles.txt	2012-10-08 14:05:57 +0000
+++ lib/lp/registry/doc/private-team-roles.txt	2015-04-15 18:39:22 +0000
@@ -60,7 +60,7 @@
 Branch ownership
 ----------------
 
-Private teams can be assigned as the owner of a branch
+Private teams can be assigned as the owner of a branch.
 
     >>> branch = factory.makeBranch()
     >>> branch.setOwner(priv_team, user=admin_user)
@@ -84,6 +84,36 @@
     private-team
 
 
+Git repositories
+================
+
+Git repository ownership
+------------------------
+
+Private teams can be assigned as the owner of a Git repository.
+
+    >>> from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
+    >>> from lp.services.features.testing import FeatureFixture
+    >>> with FeatureFixture({GIT_FEATURE_FLAG: 'on'}):
+    ...     repository = factory.makeGitRepository()
+    >>> repository.setOwner(priv_team, user=admin_user)
+
+Git repository subscriptions
+----------------------------
+
+Private teams can subscribe to Git repositories.
+
+    >>> with FeatureFixture({GIT_FEATURE_FLAG: 'on'}):
+    ...     repository = factory.makeGitRepository()
+    >>> subscription = repository.subscribe(
+    ...     priv_team,
+    ...     BranchSubscriptionNotificationLevel.DIFFSONLY,
+    ...     BranchSubscriptionDiffSize.WHOLEDIFF,
+    ...     CodeReviewNotificationLevel.STATUS, team_owner)
+    >>> print subscription.person.name
+    private-team
+
+
 PPAs
 ====
 

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2015-03-10 22:07:32 +0000
+++ lib/lp/registry/model/person.py	2015-04-15 18:39:22 +0000
@@ -2275,6 +2275,7 @@
         # Nuke all subscriptions of this person.
         removals = [
             ('BranchSubscription', 'person'),
+            ('GitSubscription', 'person'),
             ('BugSubscription', 'person'),
             ('QuestionSubscription', 'person'),
             ('SpecificationSubscription', 'person'),
@@ -2343,6 +2344,8 @@
             ('bugsummary', 'viewed_by'),
             ('bugtask', 'assignee'),
             ('emailaddress', 'person'),
+            ('gitrepository', 'owner'),
+            ('gitsubscription', 'person'),
             ('gpgkey', 'owner'),
             ('ircid', 'person'),
             ('jabberid', 'person'),

=== modified file 'lib/lp/registry/model/sharingjob.py'
--- lib/lp/registry/model/sharingjob.py	2015-02-16 13:08:52 +0000
+++ lib/lp/registry/model/sharingjob.py	2015-04-15 18:39:22 +0000
@@ -63,7 +63,11 @@
     get_branch_privacy_filter,
     )
 from lp.code.model.branchsubscription import BranchSubscription
-from lp.code.model.gitrepository import GitRepository
+from lp.code.model.gitrepository import (
+    get_git_repository_privacy_filter,
+    GitRepository,
+    )
+from lp.code.model.gitsubscription import GitSubscription
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.sharingjob import (
@@ -421,8 +425,11 @@
                     Select(
                         TeamParticipation.personID,
                         where=TeamParticipation.team == self.grantee)))
-            # XXX cjwatson 2015-02-05: Fill this in once we have
-            # GitRepositorySubscription.
+            gitrepository_filters.append(
+                In(GitSubscription.person_id,
+                    Select(
+                        TeamParticipation.personID,
+                        where=TeamParticipation.team == self.grantee)))
             specification_filters.append(
                 In(SpecificationSubscription.personID,
                     Select(
@@ -452,8 +459,20 @@
             for sub in branch_subscriptions:
                 sub.branch.unsubscribe(
                     sub.person, self.requestor, ignore_permissions=True)
-        # XXX cjwatson 2015-02-05: Fill this in once we have
-        # GitRepositorySubscription.
+        if gitrepository_filters:
+            gitrepository_filters.append(Not(
+                Or(*get_git_repository_privacy_filter(
+                    GitSubscription.person_id))))
+            gitrepository_subscriptions = IStore(GitSubscription).using(
+                GitSubscription,
+                Join(
+                    GitRepository,
+                    GitRepository.id == GitSubscription.repository_id)
+                ).find(GitSubscription, *gitrepository_filters).config(
+                    distinct=True)
+            for sub in gitrepository_subscriptions:
+                sub.repository.unsubscribe(
+                    sub.person, self.requestor, ignore_permissions=True)
         if specification_filters:
             specification_filters.append(Not(*get_specification_privacy_filter(
                 SpecificationSubscription.personID)))

=== modified file 'lib/lp/registry/personmerge.py'
--- lib/lp/registry/personmerge.py	2015-02-23 19:47:01 +0000
+++ lib/lp/registry/personmerge.py	2015-04-15 18:39:22 +0000
@@ -208,6 +208,24 @@
         ''' % vars())
 
 
+def _mergeGitSubscription(cur, from_id, to_id):
+    # Update only the GitSubscription that will not conflict.
+    cur.execute('''
+        UPDATE GitSubscription
+        SET person=%(to_id)d
+        WHERE person=%(from_id)d AND repository NOT IN
+            (
+            SELECT repository
+            FROM GitSubscription
+            WHERE person = %(to_id)d
+            )
+        ''' % vars())
+    # and delete those left over.
+    cur.execute('''
+        DELETE FROM GitSubscription WHERE person=%(from_id)d
+        ''' % vars())
+
+
 def _mergeBugAffectsPerson(cur, from_id, to_id):
     # Update only the BugAffectsPerson that will not conflict
     cur.execute('''
@@ -763,6 +781,9 @@
     _mergeBranchSubscription(cur, from_id, to_id)
     skip.append(('branchsubscription', 'person'))
 
+    _mergeGitSubscription(cur, from_id, to_id)
+    skip.append(('gitsubscription', 'person'))
+
     _mergeBugAffectsPerson(cur, from_id, to_id)
     skip.append(('bugaffectsperson', 'person'))
 

=== modified file 'lib/lp/registry/services/tests/test_sharingservice.py'
--- lib/lp/registry/services/tests/test_sharingservice.py	2015-03-05 16:23:26 +0000
+++ lib/lp/registry/services/tests/test_sharingservice.py	2015-04-15 18:39:22 +0000
@@ -1165,9 +1165,7 @@
         self._assert_revokeTeamAccessGrants(
             product, None, [branch], None, None)
 
-    # XXX cjwatson 2015-02-05: Enable this once GitRepositorySubscription is
-    # implemented.
-    def disabled_test_revokeTeamAccessGrantsGitRepositories(self):
+    def test_revokeTeamAccessGrantsGitRepositories(self):
         # Users with launchpad.Edit can delete all access for a grantee.
         owner = self.factory.makePerson()
         product = self.factory.makeProduct(owner=owner)

=== modified file 'lib/lp/registry/tests/test_sharingjob.py'
--- lib/lp/registry/tests/test_sharingjob.py	2015-03-04 18:22:06 +0000
+++ lib/lp/registry/tests/test_sharingjob.py	2015-04-15 18:39:22 +0000
@@ -333,8 +333,9 @@
         branch.subscribe(artifact_indirect_grantee,
             BranchSubscriptionNotificationLevel.NOEMAIL, None,
             CodeReviewNotificationLevel.NOEMAIL, owner)
-        # XXX cjwatson 2015-02-05: Fill this in once we have
-        # GitRepositorySubscription.
+        gitrepository.subscribe(artifact_indirect_grantee,
+            BranchSubscriptionNotificationLevel.NOEMAIL, None,
+            CodeReviewNotificationLevel.NOEMAIL, owner)
         # Subscribing somebody to a specification does not automatically
         # create an artifact grant.
         spec_artifact = self.factory.makeAccessArtifact(specification)
@@ -379,8 +380,7 @@
         self.assertIn(artifact_team_grantee, subscribers)
         self.assertIn(artifact_indirect_grantee, bug.getDirectSubscribers())
         self.assertIn(artifact_indirect_grantee, branch.subscribers)
-        # XXX cjwatson 2015-02-05: Fill this in once we have
-        # GitRepositorySubscription.
+        self.assertIn(artifact_indirect_grantee, gitrepository.subscribers)
         self.assertIn(artifact_indirect_grantee,
                       removeSecurityProxy(specification).subscribers)
 
@@ -437,7 +437,7 @@
     def _assert_gitrepository_change_unsubscribes(self, change_callback):
 
         def get_pillars(concrete_artifact):
-            return [concrete_artifact.product]
+            return [concrete_artifact.target]
 
         def get_subscribers(concrete_artifact):
             return concrete_artifact.subscribers
@@ -446,14 +446,22 @@
                            policy_team_grantee, policy_indirect_grantee,
                            artifact_team_grantee, owner):
             concrete_artifact = gitrepository
-            # XXX cjwatson 2015-02-05: Fill this in once we have
-            # GitRepositorySubscription.
+            gitrepository.subscribe(
+                policy_team_grantee,
+                BranchSubscriptionNotificationLevel.NOEMAIL,
+                None, CodeReviewNotificationLevel.NOEMAIL, owner)
+            gitrepository.subscribe(
+                policy_indirect_grantee,
+                BranchSubscriptionNotificationLevel.NOEMAIL, None,
+                CodeReviewNotificationLevel.NOEMAIL, owner)
+            gitrepository.subscribe(
+                artifact_team_grantee,
+                BranchSubscriptionNotificationLevel.NOEMAIL, None,
+                CodeReviewNotificationLevel.NOEMAIL, owner)
             return concrete_artifact, get_pillars, get_subscribers
 
-        # XXX cjwatson 2015-02-05: Uncomment once we have
-        # GitRepositorySubscription.
-        #self._assert_artifact_change_unsubscribes(
-        #    change_callback, configure_test)
+        self._assert_artifact_change_unsubscribes(
+            change_callback, configure_test)
 
     def _assert_specification_change_unsubscribes(self, change_callback):
 

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2015-04-02 01:14:22 +0000
+++ lib/lp/testing/factory.py	2015-04-15 18:39:22 +0000
@@ -1688,6 +1688,19 @@
                 information_type, registrant, verify_policy=False)
         return repository
 
+    def makeGitSubscription(self, repository=None, person=None,
+                            subscribed_by=None):
+        """Create a GitSubscription."""
+        if repository is None:
+            repository = self.makeGitRepository()
+        if person is None:
+            person = self.makePerson()
+        if subscribed_by is None:
+            subscribed_by = person
+        return repository.subscribe(removeSecurityProxy(person),
+            BranchSubscriptionNotificationLevel.NOEMAIL, None,
+            CodeReviewNotificationLevel.NOEMAIL, subscribed_by)
+
     def makeGitRefs(self, repository=None, paths=None):
         """Create and return a list of new, arbitrary GitRefs."""
         if repository is None:


Follow ups