← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-activity-model into lp:launchpad with lp:~cjwatson/launchpad/git-permissions-model as a prerequisite.

Commit message:
Add GitActivity model, currently logging additions, changes, and removals of rules and grants.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1517559 in Launchpad itself: "git fine-grained permissions"
  https://bugs.launchpad.net/launchpad/+bug/1517559

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-activity-model/+merge/354399

See https://docs.google.com/document/d/1JW_D_Tgo4X2-vPMZtShSbi3cm1iOsGcNIzeOpa5E_wA for the design and https://code.launchpad.net/~cjwatson/launchpad/db-git-activity/+merge/354398 for schema changes.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-activity-model into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2018-09-06 14:26:10 +0000
+++ database/schema/security.cfg	2018-09-06 14:26:10 +0000
@@ -187,6 +187,7 @@
 public.featureflag                      = SELECT, INSERT, UPDATE, DELETE
 public.featureflagchangelogentry        = SELECT, INSERT, UPDATE
 public.flatpackagesetinclusion          = SELECT, INSERT, UPDATE, DELETE
+public.gitactivity                      = SELECT, INSERT, UPDATE, DELETE
 public.gitgrant                         = SELECT, INSERT, UPDATE, DELETE
 public.gitjob                           = SELECT, INSERT, UPDATE, DELETE
 public.gitref                           = SELECT, INSERT, UPDATE, DELETE
@@ -2208,6 +2209,7 @@
 public.emailaddress                     = SELECT, UPDATE, DELETE
 public.faq                              = SELECT, UPDATE
 public.featureflagchangelogentry        = SELECT, UPDATE
+public.gitactivity                      = SELECT, UPDATE
 public.gitgrant                         = SELECT, UPDATE, DELETE
 public.gitrepository                    = SELECT, UPDATE
 public.gitrule                          = SELECT, UPDATE

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2018-09-06 14:26:10 +0000
+++ lib/lp/code/configure.zcml	2018-09-06 14:26:10 +0000
@@ -948,6 +948,19 @@
       for="lp.code.interfaces.gitgrant.IGitGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent"
       handler="lp.code.model.gitgrant.git_grant_modified"/>
 
+  <!-- GitActivity -->
+
+  <class class="lp.code.model.gitactivity.GitActivity">
+    <require
+        permission="launchpad.View"
+        interface="lp.code.interfaces.gitactivity.IGitActivity" />
+  </class>
+  <securedutility
+      class="lp.code.model.gitactivity.GitActivitySet"
+      provides="lp.code.interfaces.gitactivity.IGitActivitySet">
+    <allow interface="lp.code.interfaces.gitactivity.IGitActivitySet" />
+  </securedutility>
+
   <!-- GitCollection -->
 
   <class class="lp.code.model.gitcollection.GenericGitCollection">

=== modified file 'lib/lp/code/enums.py'
--- lib/lp/code/enums.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/enums.py	2018-09-06 14:26:10 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Enumerations used in the lp/code modules."""
@@ -21,6 +21,7 @@
     'CodeImportReviewStatus',
     'CodeReviewNotificationLevel',
     'CodeReviewVote',
+    'GitActivityType',
     'GitGranteeType',
     'GitObjectType',
     'GitRepositoryType',
@@ -196,6 +197,25 @@
         """)
 
 
+class GitActivityType(DBEnumeratedType):
+    """Git Activity Type
+
+    Various kinds of change can be recorded for a Git repository.
+    """
+
+    RULE_ADDED = DBItem(1, "Added access rule")
+
+    RULE_CHANGED = DBItem(2, "Changed access rule")
+
+    RULE_REMOVED = DBItem(3, "Removed access rule")
+
+    GRANT_ADDED = DBItem(4, "Added access grant")
+
+    GRANT_CHANGED = DBItem(5, "Changed access grant")
+
+    GRANT_REMOVED = DBItem(6, "Removed access grant")
+
+
 class BranchLifecycleStatusFilter(EnumeratedType):
     """Branch Lifecycle Status Filter
 

=== added file 'lib/lp/code/interfaces/gitactivity.py'
--- lib/lp/code/interfaces/gitactivity.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitactivity.py	2018-09-06 14:26:10 +0000
@@ -0,0 +1,121 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository activity logs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IGitActivity',
+    'IGitActivitySet',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Choice,
+    Datetime,
+    Int,
+    TextLine,
+    )
+
+from lp import _
+from lp.code.enums import GitActivityType
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.fields import (
+    PersonChoice,
+    PublicPersonChoice,
+    )
+
+
+class IGitActivity(Interface):
+    """An activity log entry for a Git repository."""
+
+    id = Int(title=_("ID"), readonly=True, required=True)
+
+    repository = Reference(
+        title=_("Repository"), required=True, readonly=True,
+        schema=IGitRepository,
+        description=_("The repository that this log entry is for."))
+
+    date_changed = Datetime(
+        title=_("Date changed"), required=True, readonly=True,
+        description=_("The time when this change happened."))
+
+    changer = PublicPersonChoice(
+        title=_("Changer"), required=True, readonly=True,
+        vocabulary="ValidPerson",
+        description=_("The user who made this change."))
+
+    changee = PersonChoice(
+        title=_("Changee"), required=False, readonly=True,
+        vocabulary="ValidPersonOrTeam",
+        description=_("The person or team that this change was applied to."))
+
+    what_changed = Choice(
+        title=_("What changed"), required=True, readonly=True,
+        vocabulary=GitActivityType,
+        description=_("The property of the repository that changed."))
+
+    old_value = TextLine(
+        title=_("Old value"), required=True, readonly=True,
+        description=_("The value before the change."))
+
+    new_value = TextLine(
+        title=_("New value"), required=True, readonly=True,
+        description=_("The value after the change."))
+
+
+class IGitActivitySet(Interface):
+    """Utilities for managing Git repository activity log entries."""
+
+    def logRuleAdded(grant, user):
+        """Log that an access rule was added.
+
+        :param rule: The `IGitRule` that was added.
+        :param user: The `IPerson` who added it.
+        :return: The new `IGitActivity`.
+        """
+
+    def logRuleChanged(old_grant, new_grant, user):
+        """Log that an access rule was changed.
+
+        :param old_rule: The `IGitRule` before the change.
+        :param new_rule: The `IGitRule` after the change.
+        :param user: The `IPerson` who made the change.
+        :return: The new `IGitActivity`.
+        """
+
+    def logRuleRemoved(grant, user):
+        """Log that an access rule was removed.
+
+        :param rule: The `IGitRule` that was removed.
+        :param user: The `IPerson` who removed it.
+        :return: The new `IGitActivity`.
+        """
+
+    def logGrantAdded(grant, user):
+        """Log that an access grant was added.
+
+        :param grant: The `IGitGrant` that was added.
+        :param user: The `IPerson` who added it.
+        :return: The new `IGitActivity`.
+        """
+
+    def logGrantChanged(old_grant, new_grant, user):
+        """Log that an access grant was changed.
+
+        :param old_grant: The `IGitGrant` before the change.
+        :param new_grant: The `IGitGrant` after the change.
+        :param user: The `IPerson` who made the change.
+        :return: The new `IGitActivity`.
+        """
+
+    def logGrantRemoved(grant, user):
+        """Log that an access grant was removed.
+
+        :param grant: The `IGitGrant` that was removed.
+        :param user: The `IPerson` who removed it.
+        :return: The new `IGitActivity`.
+        """

=== modified file 'lib/lp/code/interfaces/gitgrant.py'
--- lib/lp/code/interfaces/gitgrant.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/interfaces/gitgrant.py	2018-09-06 14:26:10 +0000
@@ -90,8 +90,11 @@
 class IGitGrantEdit(Interface):
     """`IGitGrant` attributes that require launchpad.Edit."""
 
-    def destroySelf():
-        """Delete this access grant."""
+    def destroySelf(user):
+        """Delete this access grant.
+
+        :param user: The `IPerson` doing the deletion.
+        """
 
 
 class IGitGrant(IGitGrantView, IGitGrantEditableAttributes, IGitGrantEdit):

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2018-09-06 14:26:10 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Git repository interfaces."""
@@ -270,6 +270,8 @@
 
     grants = Attribute("The access grants for this repository.")
 
+    activity = Attribute("The activity log entries for this repository.")
+
     @operation_parameters(
         path=TextLine(title=_("A string to look up as a path.")))
     # Really IGitRef, patched in _schema_circular_imports.py.

=== modified file 'lib/lp/code/interfaces/gitrule.py'
--- lib/lp/code/interfaces/gitrule.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/interfaces/gitrule.py	2018-09-06 14:26:10 +0000
@@ -82,8 +82,11 @@
             matching this rule.
         """
 
-    def destroySelf():
-        """Delete this rule."""
+    def destroySelf(user):
+        """Delete this rule.
+
+        :param user: The `IPerson` doing the deletion.
+        """
 
 
 class IGitRule(IGitRuleView, IGitRuleEditableAttributes, IGitRuleEdit):

=== added file 'lib/lp/code/model/gitactivity.py'
--- lib/lp/code/model/gitactivity.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitactivity.py	2018-09-06 14:26:10 +0000
@@ -0,0 +1,128 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository activity logs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'GitActivity',
+    ]
+
+import pytz
+from storm.locals import (
+    DateTime,
+    Int,
+    JSON,
+    Reference,
+    )
+from zope.interface import implementer
+
+from lp.code.enums import GitActivityType
+from lp.code.interfaces.gitactivity import (
+    IGitActivity,
+    IGitActivitySet,
+    )
+from lp.registry.interfaces.person import (
+    validate_person,
+    validate_public_person,
+    )
+from lp.services.database.constants import DEFAULT
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.stormbase import StormBase
+
+
+@implementer(IGitActivity)
+class GitActivity(StormBase):
+    """See IGitActivity`."""
+
+    __storm_table__ = 'GitActivity'
+
+    id = Int(primary=True)
+
+    repository_id = Int(name='repository', allow_none=False)
+    repository = Reference(repository_id, 'GitRepository.id')
+
+    date_changed = DateTime(
+        name='date_changed', tzinfo=pytz.UTC, allow_none=False)
+
+    changer_id = Int(
+        name='changer', allow_none=False, validator=validate_public_person)
+    changer = Reference(changer_id, 'Person.id')
+
+    changee_id = Int(
+        name='changee', allow_none=True, validator=validate_person)
+    changee = Reference(changee_id, 'Person.id')
+
+    what_changed = EnumCol(
+        dbName='what_changed', enum=GitActivityType, notNull=True)
+
+    old_value = JSON(name='old_value', allow_none=True)
+    new_value = JSON(name='new_value', allow_none=True)
+
+    def __init__(self, repository, changer, what_changed, changee=None,
+                 old_value=None, new_value=None, date_changed=DEFAULT):
+        super(GitActivity, self).__init__()
+        self.repository = repository
+        self.date_changed = date_changed
+        self.changer = changer
+        self.changee = changee
+        self.what_changed = what_changed
+        self.old_value = old_value
+        self.new_value = new_value
+
+
+def _make_rule_value(rule):
+    return {"ref_pattern": rule.ref_pattern}
+
+
+def _make_grant_value(grant):
+    return {
+        # If the grantee is a person, then we store that in
+        # GitActivity.changee; this makes it visible to person merges and so
+        # on.
+        "grantee_type": grant.grantee_type.title,
+        "can_create": grant.can_create,
+        "can_push": grant.can_push,
+        "can_force_push": grant.can_force_push,
+        }
+
+
+@implementer(IGitActivitySet)
+class GitActivitySet:
+
+    def logRuleAdded(self, rule, user):
+        return GitActivity(
+            rule.repository, user, GitActivityType.RULE_ADDED,
+            new_value=_make_rule_value(rule))
+
+    def logRuleChanged(self, old_rule, new_rule, user):
+        return GitActivity(
+            new_rule.repository, user, GitActivityType.RULE_CHANGED,
+            old_value=_make_rule_value(old_rule),
+            new_value=_make_rule_value(new_rule))
+
+    def logRuleRemoved(self, rule, user):
+        return GitActivity(
+            rule.repository, user, GitActivityType.RULE_REMOVED,
+            old_value=_make_rule_value(rule))
+
+    def logGrantAdded(self, grant, user):
+        return GitActivity(
+            grant.repository, user, GitActivityType.GRANT_ADDED,
+            changee=grant.grantee,
+            new_value=_make_grant_value(grant))
+
+    def logGrantChanged(self, old_grant, new_grant, user):
+        return GitActivity(
+            new_grant.repository, user, GitActivityType.GRANT_CHANGED,
+            changee=new_grant.grantee,
+            old_value=_make_grant_value(old_grant),
+            new_value=_make_grant_value(new_grant))
+
+    def logGrantRemoved(self, grant, user):
+        return GitActivity(
+            grant.repository, user, GitActivityType.GRANT_REMOVED,
+            changee=grant.grantee,
+            old_value=_make_grant_value(grant))

=== modified file 'lib/lp/code/model/gitgrant.py'
--- lib/lp/code/model/gitgrant.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/model/gitgrant.py	2018-09-06 14:26:10 +0000
@@ -19,11 +19,15 @@
     Reference,
     Store,
     )
+from zope.component import getUtility
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
 from lp.code.enums import GitGranteeType
+from lp.code.interfaces.gitactivity import IGitActivitySet
 from lp.code.interfaces.gitgrant import IGitGrant
 from lp.registry.interfaces.person import (
+    IPerson,
     validate_person,
     validate_public_person,
     )
@@ -39,7 +43,10 @@
     events on Git repository grants.
     """
     if event.edited_fields:
-        grant.date_last_modified = UTC_NOW
+        user = IPerson(event.user)
+        getUtility(IGitActivitySet).logGrantChanged(
+            event.object_before_modification, grant, user)
+        removeSecurityProxy(grant).date_last_modified = UTC_NOW
 
 
 @implementer(IGitGrant)
@@ -114,6 +121,7 @@
         return "<GitGrant [%s] to %s> for %r" % (
             ", ".join(permissions), grantee_name, self.rule)
 
-    def destroySelf(self):
+    def destroySelf(self, user):
         """See `IGitGrant`."""
+        getUtility(IGitActivitySet).logGrantRemoved(self, user)
         Store.of(self).remove(self)

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/model/gitrepository.py	2018-09-06 14:26:10 +0000
@@ -89,6 +89,7 @@
     notify_modified,
     )
 from lp.code.interfaces.codeimport import ICodeImportSet
+from lp.code.interfaces.gitactivity import IGitActivitySet
 from lp.code.interfaces.gitcollection import (
     IAllGitRepositories,
     IGitCollection,
@@ -109,6 +110,7 @@
 from lp.code.interfaces.revision import IRevisionSet
 from lp.code.mail.branch import send_git_repository_modified_notifications
 from lp.code.model.branchmergeproposal import BranchMergeProposal
+from lp.code.model.gitactivity import GitActivity
 from lp.code.model.gitgrant import GitGrant
 from lp.code.model.gitref import (
     GitRef,
@@ -1152,6 +1154,7 @@
             self.rule_order.append(rule.id)
         else:
             self.rule_order.insert(position, rule.id)
+        getUtility(IGitActivitySet).logRuleAdded(rule, creator)
         return rule
 
     @property
@@ -1159,6 +1162,14 @@
         """See `IGitRepository`."""
         return Store.of(self).find(GitGrant, GitGrant.repository_id == self.id)
 
+    @property
+    def activity(self):
+        """See `IGitRepository`."""
+        rows = Store.of(self).find(
+            GitActivity, GitActivity.repository_id == self.id)
+        return rows.order_by(
+            Desc(GitActivity.date_changed), Desc(GitActivity.id))
+
     def canBeDeleted(self):
         """See `IGitRepository`."""
         # Can't delete if the repository is associated with anything.

=== modified file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/model/gitrule.py	2018-09-06 14:26:10 +0000
@@ -18,11 +18,17 @@
     Store,
     Unicode,
     )
+from zope.component import getUtility
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
+from lp.code.interfaces.gitactivity import IGitActivitySet
 from lp.code.interfaces.gitrule import IGitRule
 from lp.code.model.gitgrant import GitGrant
-from lp.registry.interfaces.person import validate_public_person
+from lp.registry.interfaces.person import (
+    IPerson,
+    validate_public_person,
+    )
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
@@ -37,7 +43,10 @@
     events on Git repository rules.
     """
     if event.edited_fields:
-        rule.date_last_modified = UTC_NOW
+        user = IPerson(event.user)
+        getUtility(IGitActivitySet).logRuleChanged(
+            event.object_before_modification, rule, user)
+        removeSecurityProxy(rule).date_last_modified = UTC_NOW
 
 
 @implementer(IGitRule)
@@ -81,12 +90,15 @@
     def addGrant(self, grantee, grantor, can_create=False, can_push=False,
                  can_force_push=False):
         """See `IGitRule`."""
-        return GitGrant(
+        grant = GitGrant(
             rule=self, grantee=grantee, can_create=can_create,
             can_push=can_push, can_force_push=can_force_push, grantor=grantor,
             date_created=DEFAULT)
+        getUtility(IGitActivitySet).logGrantAdded(grant, grantor)
+        return grant
 
-    def destroySelf(self):
+    def destroySelf(self, user):
         """See `IGitRule`."""
+        getUtility(IGitActivitySet).logRuleRemoved(self, user)
         self.repository.rule_order.remove(self.id)
         Store.of(self).remove(self)

=== added file 'lib/lp/code/model/tests/test_gitactivity.py'
--- lib/lp/code/model/tests/test_gitactivity.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/tests/test_gitactivity.py	2018-09-06 14:26:10 +0000
@@ -0,0 +1,48 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Git repository activity logs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from storm.store import Store
+from testtools.matchers import MatchesStructure
+
+from lp.code.enums import GitActivityType
+from lp.code.interfaces.gitactivity import IGitActivity
+from lp.code.model.gitactivity import GitActivity
+from lp.services.database.sqlbase import get_transaction_timestamp
+from lp.testing import (
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestGitActivity(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_implements_IGitActivity(self):
+        repository = self.factory.makeGitRepository()
+        activity = GitActivity(
+            repository, repository.owner, GitActivityType.RULE_ADDED)
+        verifyObject(IGitActivity, activity)
+
+    def test_properties(self):
+        repository = self.factory.makeGitRepository()
+        changee = self.factory.makePerson()
+        activity = GitActivity(
+            repository, repository.owner, GitActivityType.RULE_ADDED,
+            changee=changee, old_value={"old": None}, new_value={"new": None})
+        now = get_transaction_timestamp(Store.of(activity))
+        self.assertThat(activity, MatchesStructure.byEquality(
+            repository=repository,
+            date_changed=now,
+            changer=repository.owner,
+            changee=changee,
+            what_changed=GitActivityType.RULE_ADDED,
+            old_value={"old": None},
+            new_value={"new": None}))

=== modified file 'lib/lp/code/model/tests/test_gitgrant.py'
--- lib/lp/code/model/tests/test_gitgrant.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/model/tests/test_gitgrant.py	2018-09-06 14:26:10 +0000
@@ -7,15 +7,23 @@
 
 __metaclass__ = type
 
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
 from storm.store import Store
 from testtools.matchers import (
     Equals,
     Is,
+    MatchesDict,
     MatchesSetwise,
     MatchesStructure,
     )
+from zope.event import notify
+from zope.interface import providedBy
 
-from lp.code.enums import GitGranteeType
+from lp.code.enums import (
+    GitActivityType,
+    GitGranteeType,
+    )
 from lp.code.interfaces.gitgrant import IGitGrant
 from lp.services.database.sqlbase import get_transaction_timestamp
 from lp.testing import (
@@ -92,6 +100,80 @@
             "<GitGrant [push] to ~%s> for %r" % (grantee.name, rule),
             repr(grant))
 
+    def test_activity_grant_added(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        grant = self.factory.makeGitGrant(
+            repository=repository, grantor=member, can_push=True)
+        self.assertThat(repository.activity.first(), MatchesStructure(
+            repository=Equals(repository),
+            changer=Equals(member),
+            changee=Equals(grant.grantee),
+            what_changed=Equals(GitActivityType.GRANT_ADDED),
+            old_value=Is(None),
+            new_value=MatchesDict({
+                "grantee_type": Equals("Person"),
+                "can_create": Is(False),
+                "can_push": Is(True),
+                "can_force_push": Is(False),
+                })))
+
+    def test_activity_grant_changed(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        grant = self.factory.makeGitGrant(
+            repository=repository, grantee=GitGranteeType.REPOSITORY_OWNER,
+            can_create=True)
+        grant_before_modification = Snapshot(
+            grant, providing=providedBy(grant))
+        with person_logged_in(member):
+            grant.can_create = False
+            grant.can_force_push = True
+            notify(ObjectModifiedEvent(
+                grant, grant_before_modification,
+                ["can_create", "can_force_push"]))
+        self.assertThat(repository.activity.first(), MatchesStructure(
+            repository=Equals(repository),
+            changer=Equals(member),
+            changee=Is(None),
+            what_changed=Equals(GitActivityType.GRANT_CHANGED),
+            old_value=MatchesDict({
+                "grantee_type": Equals("Repository owner"),
+                "can_create": Is(True),
+                "can_push": Is(False),
+                "can_force_push": Is(False),
+                }),
+            new_value=MatchesDict({
+                "grantee_type": Equals("Repository owner"),
+                "can_create": Is(False),
+                "can_push": Is(False),
+                "can_force_push": Is(True),
+                })))
+
+    def test_activity_grant_removed(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        grant = self.factory.makeGitGrant(
+            repository=repository, can_create=True, can_push=True)
+        grantee = grant.grantee
+        with person_logged_in(member):
+            grant.destroySelf(member)
+        self.assertThat(repository.activity.first(), MatchesStructure(
+            repository=Equals(repository),
+            changer=Equals(member),
+            changee=Equals(grantee),
+            what_changed=Equals(GitActivityType.GRANT_REMOVED),
+            old_value=MatchesDict({
+                "grantee_type": Equals("Person"),
+                "can_create": Is(True),
+                "can_push": Is(True),
+                "can_force_push": Is(False),
+                }),
+            new_value=Is(None)))
+
     def test_destroySelf(self):
         rule = self.factory.makeGitRule()
         grants = [
@@ -101,5 +183,5 @@
             self.factory.makeGitGrant(rule=rule, can_push=True),
             ]
         with person_logged_in(rule.repository.owner):
-            grants[1].destroySelf()
+            grants[1].destroySelf(rule.repository.owner)
         self.assertThat(rule.grants, MatchesSetwise(Equals(grants[0])))

=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
--- lib/lp/code/model/tests/test_gitrule.py	2018-09-06 14:26:10 +0000
+++ lib/lp/code/model/tests/test_gitrule.py	2018-09-06 14:26:10 +0000
@@ -7,18 +7,27 @@
 
 __metaclass__ = type
 
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
 from storm.store import Store
 from testtools.matchers import (
     Equals,
     Is,
+    MatchesDict,
     MatchesSetwise,
     MatchesStructure,
     )
+from zope.event import notify
+from zope.interface import providedBy
 
-from lp.code.enums import GitGranteeType
+from lp.code.enums import (
+    GitActivityType,
+    GitGranteeType,
+    )
 from lp.code.interfaces.gitrule import IGitRule
 from lp.services.database.sqlbase import get_transaction_timestamp
 from lp.testing import (
+    person_logged_in,
     TestCaseWithFactory,
     verifyObject,
     )
@@ -90,3 +99,55 @@
                 can_create=Is(False),
                 can_push=Is(False),
                 can_force_push=Is(True))))
+
+    def test_activity_rule_added(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*",
+            creator=member)
+        self.assertThat(repository.activity.one(), MatchesStructure(
+            repository=Equals(repository),
+            changer=Equals(member),
+            changee=Is(None),
+            what_changed=Equals(GitActivityType.RULE_ADDED),
+            old_value=Is(None),
+            new_value=MatchesDict(
+                {"ref_pattern": Equals("refs/heads/stable/*")})))
+
+    def test_activity_rule_changed(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        rule_before_modification = Snapshot(rule, providing=providedBy(rule))
+        with person_logged_in(member):
+            rule.ref_pattern = "refs/heads/other/*"
+            notify(ObjectModifiedEvent(
+                rule, rule_before_modification, ["ref_pattern"]))
+        self.assertThat(repository.activity.first(), MatchesStructure(
+            repository=Equals(repository),
+            changer=Equals(member),
+            changee=Is(None),
+            what_changed=Equals(GitActivityType.RULE_CHANGED),
+            old_value=MatchesDict({"ref_pattern": Equals("refs/heads/*")}),
+            new_value=MatchesDict(
+                {"ref_pattern": Equals("refs/heads/other/*")})))
+
+    def test_activity_rule_removed(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        with person_logged_in(member):
+            rule.destroySelf(member)
+        self.assertThat(repository.activity.first(), MatchesStructure(
+            repository=Equals(repository),
+            changer=Equals(member),
+            changee=Is(None),
+            what_changed=Equals(GitActivityType.RULE_REMOVED),
+            old_value=MatchesDict({"ref_pattern": Equals("refs/heads/*")}),
+            new_value=Is(None)))

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2018-09-06 14:26:10 +0000
+++ lib/lp/security.py	2018-09-06 14:26:10 +0000
@@ -81,6 +81,7 @@
     )
 from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
 from lp.code.interfaces.diff import IPreviewDiff
+from lp.code.interfaces.gitactivity import IGitActivity
 from lp.code.interfaces.gitcollection import IGitCollection
 from lp.code.interfaces.gitgrant import IGitGrant
 from lp.code.interfaces.gitref import IGitRef
@@ -2381,6 +2382,15 @@
         super(EditGitGrant, self).__init__(obj, obj.repository)
 
 
+class ViewGitActivity(DelegatedAuthorization):
+    """Anyone who can see a Git repository can see its activity logs."""
+    permission = 'launchpad.View'
+    usedfor = IGitActivity
+
+    def __init__(self, obj):
+        super(ViewGitActivity, self).__init__(obj, obj.repository)
+
+
 class AdminDistroSeriesTranslations(AuthorizationBase):
     permission = 'launchpad.TranslationsAdmin'
     usedfor = IDistroSeries

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2018-09-06 14:26:10 +0000
+++ lib/lp/testing/factory.py	2018-09-06 14:26:10 +0000
@@ -1839,10 +1839,11 @@
             return repository.addRule(ref_pattern, creator, position=position)
 
     def makeGitGrant(self, rule=None, grantee=None, grantor=None,
-                     can_create=False, can_push=False, can_force_push=False):
+                     can_create=False, can_push=False, can_force_push=False,
+                     **rule_kwargs):
         """Create a Git repository access grant."""
         if rule is None:
-            rule = self.makeGitRule()
+            rule = self.makeGitRule(**rule_kwargs)
         if grantee is None:
             grantee = self.makePerson()
         if grantor is None:


Follow ups