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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18794
Proposed branch: lp:~cjwatson/launchpad/git-activity-model
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-permissions-model
Diff against target: 960 lines (+621/-22)
13 files modified
lib/lp/code/configure.zcml (+13/-0)
lib/lp/code/enums.py (+23/-1)
lib/lp/code/interfaces/gitactivity.py (+135/-0)
lib/lp/code/interfaces/gitrepository.py (+9/-2)
lib/lp/code/interfaces/gitrule.py (+11/-4)
lib/lp/code/model/gitactivity.py (+145/-0)
lib/lp/code/model/gitrepository.py (+16/-2)
lib/lp/code/model/gitrule.py (+17/-3)
lib/lp/code/model/tests/test_gitactivity.py (+48/-0)
lib/lp/code/model/tests/test_gitrepository.py (+15/-4)
lib/lp/code/model/tests/test_gitrule.py (+177/-4)
lib/lp/security.py (+10/-0)
lib/lp/testing/factory.py (+2/-2)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-activity-model
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+354399@code.launchpad.net

Commit message

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

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/configure.zcml'
2--- lib/lp/code/configure.zcml 2018-09-12 16:26:15 +0000
3+++ lib/lp/code/configure.zcml 2018-10-12 16:44:41 +0000
4@@ -948,6 +948,19 @@
5 for="lp.code.interfaces.gitrule.IGitRuleGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent"
6 handler="lp.code.model.gitrule.git_rule_grant_modified"/>
7
8+ <!-- GitActivity -->
9+
10+ <class class="lp.code.model.gitactivity.GitActivity">
11+ <require
12+ permission="launchpad.View"
13+ interface="lp.code.interfaces.gitactivity.IGitActivity" />
14+ </class>
15+ <securedutility
16+ class="lp.code.model.gitactivity.GitActivitySet"
17+ provides="lp.code.interfaces.gitactivity.IGitActivitySet">
18+ <allow interface="lp.code.interfaces.gitactivity.IGitActivitySet" />
19+ </securedutility>
20+
21 <!-- GitCollection -->
22
23 <class class="lp.code.model.gitcollection.GenericGitCollection">
24
25=== modified file 'lib/lp/code/enums.py'
26--- lib/lp/code/enums.py 2018-09-03 16:07:56 +0000
27+++ lib/lp/code/enums.py 2018-10-12 16:44:41 +0000
28@@ -1,4 +1,4 @@
29-# Copyright 2009-2017 Canonical Ltd. This software is licensed under the
30+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
31 # GNU Affero General Public License version 3 (see the file LICENSE).
32
33 """Enumerations used in the lp/code modules."""
34@@ -21,6 +21,7 @@
35 'CodeImportReviewStatus',
36 'CodeReviewNotificationLevel',
37 'CodeReviewVote',
38+ 'GitActivityType',
39 'GitGranteeType',
40 'GitObjectType',
41 'GitRepositoryType',
42@@ -196,6 +197,27 @@
43 """)
44
45
46+class GitActivityType(DBEnumeratedType):
47+ """Git Activity Type
48+
49+ Various kinds of change can be recorded for a Git repository.
50+ """
51+
52+ RULE_ADDED = DBItem(1, "Added access rule")
53+
54+ RULE_CHANGED = DBItem(2, "Changed access rule")
55+
56+ RULE_REMOVED = DBItem(3, "Removed access rule")
57+
58+ RULE_MOVED = DBItem(4, "Moved access rule")
59+
60+ GRANT_ADDED = DBItem(11, "Added access grant")
61+
62+ GRANT_CHANGED = DBItem(12, "Changed access grant")
63+
64+ GRANT_REMOVED = DBItem(13, "Removed access grant")
65+
66+
67 class BranchLifecycleStatusFilter(EnumeratedType):
68 """Branch Lifecycle Status Filter
69
70
71=== added file 'lib/lp/code/interfaces/gitactivity.py'
72--- lib/lp/code/interfaces/gitactivity.py 1970-01-01 00:00:00 +0000
73+++ lib/lp/code/interfaces/gitactivity.py 2018-10-12 16:44:41 +0000
74@@ -0,0 +1,135 @@
75+# Copyright 2018 Canonical Ltd. This software is licensed under the
76+# GNU Affero General Public License version 3 (see the file LICENSE).
77+
78+"""Git repository activity logs."""
79+
80+from __future__ import absolute_import, print_function, unicode_literals
81+
82+__metaclass__ = type
83+__all__ = [
84+ 'IGitActivity',
85+ 'IGitActivitySet',
86+ ]
87+
88+from lazr.restful.fields import Reference
89+from zope.interface import Interface
90+from zope.schema import (
91+ Choice,
92+ Datetime,
93+ Dict,
94+ Int,
95+ Text,
96+ TextLine,
97+ )
98+
99+from lp import _
100+from lp.code.enums import GitActivityType
101+from lp.code.interfaces.gitrepository import IGitRepository
102+from lp.services.fields import (
103+ PersonChoice,
104+ PublicPersonChoice,
105+ )
106+
107+
108+class IGitActivity(Interface):
109+ """An activity log entry for a Git repository."""
110+
111+ id = Int(title=_("ID"), readonly=True, required=True)
112+
113+ repository = Reference(
114+ title=_("Repository"), required=True, readonly=True,
115+ schema=IGitRepository,
116+ description=_("The repository that this log entry is for."))
117+
118+ date_changed = Datetime(
119+ title=_("Date changed"), required=True, readonly=True,
120+ description=_("The time when this change happened."))
121+
122+ changer = PublicPersonChoice(
123+ title=_("Changer"), required=True, readonly=True,
124+ vocabulary="ValidPerson",
125+ description=_("The user who made this change."))
126+
127+ changee = PersonChoice(
128+ title=_("Changee"), required=False, readonly=True,
129+ vocabulary="ValidPersonOrTeam",
130+ description=_("The person or team that this change was applied to."))
131+
132+ what_changed = Choice(
133+ title=_("What changed"), required=True, readonly=True,
134+ vocabulary=GitActivityType,
135+ description=_("The property of the repository that changed."))
136+
137+ old_value = Dict(
138+ title=_("Old value"), required=False, readonly=True,
139+ description=_("Representation of the value before the change."),
140+ key_type=TextLine(), value_type=Text())
141+
142+ new_value = Dict(
143+ title=_("New value"), required=False, readonly=True,
144+ description=_("Representation of the value after the change."),
145+ key_type=TextLine(), value_type=Text())
146+
147+
148+class IGitActivitySet(Interface):
149+ """Utilities for managing Git repository activity log entries."""
150+
151+ def logRuleAdded(rule, user):
152+ """Log that an access rule was added.
153+
154+ :param rule: The `IGitRule` that was added.
155+ :param user: The `IPerson` who added it.
156+ :return: The new `IGitActivity`.
157+ """
158+
159+ def logRuleChanged(old_rule, new_rule, user):
160+ """Log that an access rule was changed.
161+
162+ :param old_rule: The `IGitRule` before the change.
163+ :param new_rule: The `IGitRule` after the change.
164+ :param user: The `IPerson` who made the change.
165+ :return: The new `IGitActivity`.
166+ """
167+
168+ def logRuleRemoved(rule, user):
169+ """Log that an access rule was removed.
170+
171+ :param rule: The `IGitRule` that was removed.
172+ :param user: The `IPerson` who removed it.
173+ :return: The new `IGitActivity`.
174+ """
175+
176+ def logRuleMoved(rule, old_position, new_position, user):
177+ """Log that an access rule was moved to a different position.
178+
179+ :param rule: The `IGitRule` that was moved.
180+ :param old_position: The old position in its repository's order.
181+ :param new_position: The new position in its repository's order.
182+ :param user: The `IPerson` who moved it.
183+ :return: The new `IGitActivity`.
184+ """
185+
186+ def logGrantAdded(grant, user):
187+ """Log that an access grant was added.
188+
189+ :param grant: The `IGitRuleGrant` that was added.
190+ :param user: The `IPerson` who added it.
191+ :return: The new `IGitActivity`.
192+ """
193+
194+ def logGrantChanged(old_grant, new_grant, user):
195+ """Log that an access grant was changed.
196+
197+ :param old_grant: The `IGitRuleGrant` before the change.
198+ :param new_grant: The `IGitRuleGrant` after the change.
199+ :param user: The `IPerson` who made the change.
200+ :return: The new `IGitActivity`.
201+ """
202+
203+ def logGrantRemoved(grant, user):
204+ """Log that an access grant was removed.
205+
206+ :param grant: The `IGitRuleGrant` that was removed.
207+ :param user: The `IPerson` who removed it.
208+ :return: The new `IGitActivity`.
209+ """
210
211=== modified file 'lib/lp/code/interfaces/gitrepository.py'
212--- lib/lp/code/interfaces/gitrepository.py 2018-10-03 13:19:08 +0000
213+++ lib/lp/code/interfaces/gitrepository.py 2018-10-12 16:44:41 +0000
214@@ -1,4 +1,4 @@
215-# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
216+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
217 # GNU Affero General Public License version 3 (see the file LICENSE).
218
219 """Git repository interfaces."""
220@@ -622,6 +622,12 @@
221 :return: The diff as a binary string.
222 """
223
224+ def getActivity():
225+ """Get activity log entries for this repository.
226+
227+ :return: A `ResultSet` of `IGitActivity`.
228+ """
229+
230
231 class IGitRepositoryModerateAttributes(Interface):
232 """IGitRepository attributes that can be edited by more than one community.
233@@ -729,7 +735,7 @@
234 None to append it.
235 """
236
237- def moveRule(rule, position):
238+ def moveRule(rule, position, user):
239 """Move a rule to a new position in its repository's rule order.
240
241 :param rule: The `IGitRule` to move.
242@@ -737,6 +743,7 @@
243 the start, while `len(repository.rules)` puts the rule at the
244 end. If the new position is before the end of the list, then
245 other rules are shifted to later positions to make room.
246+ :param user: The `IPerson` who is moving the rule.
247 """
248
249 @export_read_operation()
250
251=== modified file 'lib/lp/code/interfaces/gitrule.py'
252--- lib/lp/code/interfaces/gitrule.py 2018-10-03 13:18:27 +0000
253+++ lib/lp/code/interfaces/gitrule.py 2018-10-12 16:44:41 +0000
254@@ -100,8 +100,11 @@
255 matching this rule.
256 """
257
258- def destroySelf():
259- """Delete this rule."""
260+ def destroySelf(user):
261+ """Delete this rule.
262+
263+ :param user: The `IPerson` doing the deletion.
264+ """
265
266
267 class IGitRule(IGitRuleView, IGitRuleEditableAttributes, IGitRuleEdit):
268@@ -169,8 +172,12 @@
269 class IGitRuleGrantEdit(Interface):
270 """`IGitRuleGrant` attributes that require launchpad.Edit."""
271
272- def destroySelf():
273- """Delete this access grant."""
274+ def destroySelf(user=None):
275+ """Delete this access grant.
276+
277+ :param user: The `IPerson` doing the deletion, or None if the
278+ deletion should not be logged.
279+ """
280
281
282 class IGitRuleGrant(IGitRuleGrantView, IGitRuleGrantEditableAttributes,
283
284=== added file 'lib/lp/code/model/gitactivity.py'
285--- lib/lp/code/model/gitactivity.py 1970-01-01 00:00:00 +0000
286+++ lib/lp/code/model/gitactivity.py 2018-10-12 16:44:41 +0000
287@@ -0,0 +1,145 @@
288+# Copyright 2018 Canonical Ltd. This software is licensed under the
289+# GNU Affero General Public License version 3 (see the file LICENSE).
290+
291+"""Git repository activity logs."""
292+
293+from __future__ import absolute_import, print_function, unicode_literals
294+
295+__metaclass__ = type
296+__all__ = [
297+ 'GitActivity',
298+ ]
299+
300+import pytz
301+from storm.locals import (
302+ DateTime,
303+ Int,
304+ JSON,
305+ Reference,
306+ )
307+from zope.interface import implementer
308+
309+from lp.code.enums import GitActivityType
310+from lp.code.interfaces.gitactivity import (
311+ IGitActivity,
312+ IGitActivitySet,
313+ )
314+from lp.registry.interfaces.person import (
315+ validate_person,
316+ validate_public_person,
317+ )
318+from lp.services.database.constants import DEFAULT
319+from lp.services.database.enumcol import EnumCol
320+from lp.services.database.stormbase import StormBase
321+
322+
323+@implementer(IGitActivity)
324+class GitActivity(StormBase):
325+ """See IGitActivity`."""
326+
327+ __storm_table__ = 'GitActivity'
328+
329+ id = Int(primary=True)
330+
331+ repository_id = Int(name='repository', allow_none=False)
332+ repository = Reference(repository_id, 'GitRepository.id')
333+
334+ date_changed = DateTime(
335+ name='date_changed', tzinfo=pytz.UTC, allow_none=False)
336+
337+ changer_id = Int(
338+ name='changer', allow_none=False, validator=validate_public_person)
339+ changer = Reference(changer_id, 'Person.id')
340+
341+ changee_id = Int(
342+ name='changee', allow_none=True, validator=validate_person)
343+ changee = Reference(changee_id, 'Person.id')
344+
345+ what_changed = EnumCol(
346+ dbName='what_changed', enum=GitActivityType, notNull=True)
347+
348+ old_value = JSON(name='old_value', allow_none=True)
349+ new_value = JSON(name='new_value', allow_none=True)
350+
351+ def __init__(self, repository, changer, what_changed, changee=None,
352+ old_value=None, new_value=None, date_changed=DEFAULT):
353+ super(GitActivity, self).__init__()
354+ self.repository = repository
355+ self.date_changed = date_changed
356+ self.changer = changer
357+ self.changee = changee
358+ self.what_changed = what_changed
359+ self.old_value = old_value
360+ self.new_value = new_value
361+
362+
363+def _make_rule_value(rule, position=None):
364+ return {
365+ "ref_pattern": rule.ref_pattern,
366+ "position": position if position is not None else rule.position,
367+ }
368+
369+
370+def _make_grant_value(grant):
371+ return {
372+ # If the grantee is a person, then we store that in
373+ # GitActivity.changee; this makes it visible to person merges and so
374+ # on.
375+ "changee_type": grant.grantee_type.title,
376+ "ref_pattern": grant.rule.ref_pattern,
377+ "can_create": grant.can_create,
378+ "can_push": grant.can_push,
379+ "can_force_push": grant.can_force_push,
380+ }
381+
382+
383+@implementer(IGitActivitySet)
384+class GitActivitySet:
385+
386+ def logRuleAdded(self, rule, user):
387+ """See `IGitActivitySet`."""
388+ return GitActivity(
389+ rule.repository, user, GitActivityType.RULE_ADDED,
390+ new_value=_make_rule_value(rule))
391+
392+ def logRuleChanged(self, old_rule, new_rule, user):
393+ """See `IGitActivitySet`."""
394+ return GitActivity(
395+ new_rule.repository, user, GitActivityType.RULE_CHANGED,
396+ old_value=_make_rule_value(old_rule),
397+ new_value=_make_rule_value(new_rule))
398+
399+ def logRuleRemoved(self, rule, user):
400+ """See `IGitActivitySet`."""
401+ return GitActivity(
402+ rule.repository, user, GitActivityType.RULE_REMOVED,
403+ old_value=_make_rule_value(rule))
404+
405+ def logRuleMoved(self, rule, old_position, new_position, user):
406+ """See `IGitActivitySet`."""
407+ return GitActivity(
408+ rule.repository, user, GitActivityType.RULE_MOVED,
409+ old_value=_make_rule_value(rule, position=old_position),
410+ new_value=_make_rule_value(rule, position=new_position))
411+
412+ def logGrantAdded(self, grant, user):
413+ """See `IGitActivitySet`."""
414+ return GitActivity(
415+ grant.repository, user, GitActivityType.GRANT_ADDED,
416+ changee=grant.grantee,
417+ new_value=_make_grant_value(grant))
418+
419+ def logGrantChanged(self, old_grant, new_grant, user):
420+ """See `IGitActivitySet`."""
421+ return GitActivity(
422+ new_grant.repository, user, GitActivityType.GRANT_CHANGED,
423+ changee=new_grant.grantee,
424+ old_value=_make_grant_value(old_grant),
425+ new_value=_make_grant_value(new_grant))
426+
427+ def logGrantRemoved(self, grant, user):
428+ """See `IGitActivitySet`."""
429+ return GitActivity(
430+ grant.repository, user, GitActivityType.GRANT_REMOVED,
431+ changee=grant.grantee,
432+ old_value=_make_grant_value(grant))
433
434=== modified file 'lib/lp/code/model/gitrepository.py'
435--- lib/lp/code/model/gitrepository.py 2018-10-05 15:35:34 +0000
436+++ lib/lp/code/model/gitrepository.py 2018-10-12 16:44:41 +0000
437@@ -88,6 +88,7 @@
438 notify_modified,
439 )
440 from lp.code.interfaces.codeimport import ICodeImportSet
441+from lp.code.interfaces.gitactivity import IGitActivitySet
442 from lp.code.interfaces.gitcollection import (
443 IAllGitRepositories,
444 IGitCollection,
445@@ -108,6 +109,7 @@
446 from lp.code.interfaces.revision import IRevisionSet
447 from lp.code.mail.branch import send_git_repository_modified_notifications
448 from lp.code.model.branchmergeproposal import BranchMergeProposal
449+from lp.code.model.gitactivity import GitActivity
450 from lp.code.model.gitref import (
451 GitRef,
452 GitRefDefault,
453@@ -1166,19 +1168,24 @@
454 else:
455 rules.insert(position, rule)
456 self._syncRulePositions(rules)
457+ getUtility(IGitActivitySet).logRuleAdded(rule, creator)
458 return rule
459
460- def moveRule(self, rule, position):
461+ def moveRule(self, rule, position, user):
462 """See `IGitRepository`."""
463 if rule.repository != self:
464 raise ValueError("%r does not belong to %r" % (rule, self))
465 if position < 0:
466 raise ValueError("Negative positions are not supported")
467- if position != rule.position:
468+ current_position = rule.position
469+ if position != current_position:
470 rules = list(self.rules)
471 rules.remove(rule)
472 rules.insert(position, rule)
473 self._syncRulePositions(rules)
474+ if rule.position != current_position:
475+ getUtility(IGitActivitySet).logRuleMoved(
476+ rule, current_position, rule.position, user)
477
478 @property
479 def grants(self):
480@@ -1186,6 +1193,12 @@
481 return Store.of(self).find(
482 GitRuleGrant, GitRuleGrant.repository_id == self.id)
483
484+ def getActivity(self):
485+ """See `IGitRepository`."""
486+ clauses = [GitActivity.repository_id == self.id]
487+ return Store.of(self).find(GitActivity, *clauses).order_by(
488+ Desc(GitActivity.date_changed), Desc(GitActivity.id))
489+
490 def canBeDeleted(self):
491 """See `IGitRepository`."""
492 # Can't delete if the repository is associated with anything.
493@@ -1317,6 +1330,7 @@
494 self._deleteRepositorySubscriptions()
495 self._deleteJobs()
496 getUtility(IWebhookSet).delete(self.webhooks)
497+ self.getActivity().remove()
498 # We intentionally skip the usual destructors; the only other useful
499 # thing they do is to log the removal activity, and we remove the
500 # activity logs for removed repositories anyway.
501
502=== modified file 'lib/lp/code/model/gitrule.py'
503--- lib/lp/code/model/gitrule.py 2018-10-05 14:48:42 +0000
504+++ lib/lp/code/model/gitrule.py 2018-10-12 16:44:41 +0000
505@@ -21,15 +21,18 @@
506 Store,
507 Unicode,
508 )
509+from zope.component import getUtility
510 from zope.interface import implementer
511 from zope.security.proxy import removeSecurityProxy
512
513 from lp.code.enums import GitGranteeType
514+from lp.code.interfaces.gitactivity import IGitActivitySet
515 from lp.code.interfaces.gitrule import (
516 IGitRule,
517 IGitRuleGrant,
518 )
519 from lp.registry.interfaces.person import (
520+ IPerson,
521 validate_person,
522 validate_public_person,
523 )
524@@ -48,6 +51,9 @@
525 events on Git repository rules.
526 """
527 if event.edited_fields:
528+ user = IPerson(event.user)
529+ getUtility(IGitActivitySet).logRuleChanged(
530+ event.object_before_modification, rule, user)
531 removeSecurityProxy(rule).date_last_modified = UTC_NOW
532
533
534@@ -105,13 +111,16 @@
535 def addGrant(self, grantee, grantor, can_create=False, can_push=False,
536 can_force_push=False):
537 """See `IGitRule`."""
538- return GitRuleGrant(
539+ grant = GitRuleGrant(
540 rule=self, grantee=grantee, can_create=can_create,
541 can_push=can_push, can_force_push=can_force_push, grantor=grantor,
542 date_created=DEFAULT)
543+ getUtility(IGitActivitySet).logGrantAdded(grant, grantor)
544+ return grant
545
546- def destroySelf(self):
547+ def destroySelf(self, user):
548 """See `IGitRule`."""
549+ getUtility(IGitActivitySet).logRuleRemoved(self, user)
550 for grant in self.grants:
551 grant.destroySelf()
552 rules = list(self.repository.rules)
553@@ -127,6 +136,9 @@
554 events on Git repository grants.
555 """
556 if event.edited_fields:
557+ user = IPerson(event.user)
558+ getUtility(IGitActivitySet).logGrantChanged(
559+ event.object_before_modification, grant, user)
560 removeSecurityProxy(grant).date_last_modified = UTC_NOW
561
562
563@@ -203,6 +215,8 @@
564 ", ".join(permissions), grantee_name, self.repository.unique_name,
565 self.rule.ref_pattern)
566
567- def destroySelf(self):
568+ def destroySelf(self, user=None):
569 """See `IGitRuleGrant`."""
570+ if user is not None:
571+ getUtility(IGitActivitySet).logGrantRemoved(self, user)
572 Store.of(self).remove(self)
573
574=== added file 'lib/lp/code/model/tests/test_gitactivity.py'
575--- lib/lp/code/model/tests/test_gitactivity.py 1970-01-01 00:00:00 +0000
576+++ lib/lp/code/model/tests/test_gitactivity.py 2018-10-12 16:44:41 +0000
577@@ -0,0 +1,48 @@
578+# Copyright 2018 Canonical Ltd. This software is licensed under the
579+# GNU Affero General Public License version 3 (see the file LICENSE).
580+
581+"""Tests for Git repository activity logs."""
582+
583+from __future__ import absolute_import, print_function, unicode_literals
584+
585+__metaclass__ = type
586+
587+from storm.store import Store
588+from testtools.matchers import MatchesStructure
589+
590+from lp.code.enums import GitActivityType
591+from lp.code.interfaces.gitactivity import IGitActivity
592+from lp.code.model.gitactivity import GitActivity
593+from lp.services.database.sqlbase import get_transaction_timestamp
594+from lp.testing import (
595+ TestCaseWithFactory,
596+ verifyObject,
597+ )
598+from lp.testing.layers import DatabaseFunctionalLayer
599+
600+
601+class TestGitActivity(TestCaseWithFactory):
602+
603+ layer = DatabaseFunctionalLayer
604+
605+ def test_implements_IGitActivity(self):
606+ repository = self.factory.makeGitRepository()
607+ activity = GitActivity(
608+ repository, repository.owner, GitActivityType.RULE_ADDED)
609+ verifyObject(IGitActivity, activity)
610+
611+ def test_properties(self):
612+ repository = self.factory.makeGitRepository()
613+ changee = self.factory.makePerson()
614+ activity = GitActivity(
615+ repository, repository.owner, GitActivityType.RULE_ADDED,
616+ changee=changee, old_value={"old": None}, new_value={"new": None})
617+ now = get_transaction_timestamp(Store.of(activity))
618+ self.assertThat(activity, MatchesStructure.byEquality(
619+ repository=repository,
620+ date_changed=now,
621+ changer=repository.owner,
622+ changee=changee,
623+ what_changed=GitActivityType.RULE_ADDED,
624+ old_value={"old": None},
625+ new_value={"new": None}))
626
627=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
628--- lib/lp/code/model/tests/test_gitrepository.py 2018-10-05 15:35:34 +0000
629+++ lib/lp/code/model/tests/test_gitrepository.py 2018-10-12 16:44:41 +0000
630@@ -87,6 +87,7 @@
631 UpdatePreviewDiffJob,
632 )
633 from lp.code.model.codereviewcomment import CodeReviewComment
634+from lp.code.model.gitactivity import GitActivity
635 from lp.code.model.gitjob import (
636 GitJob,
637 GitJobType,
638@@ -542,10 +543,18 @@
639 def test_related_rules_and_grants_deleted(self):
640 rule = self.factory.makeGitRule(repository=self.repository)
641 grant = self.factory.makeGitRuleGrant(rule=rule)
642+ store = Store.of(self.repository)
643+ repository_id = self.repository.id
644+ activities = store.find(
645+ GitActivity, GitActivity.repository_id == repository_id)
646+ self.assertNotEqual([], list(activities))
647 self.repository.destroySelf()
648 transaction.commit()
649 self.assertRaises(LostObjectError, getattr, grant, 'rule')
650 self.assertRaises(LostObjectError, getattr, rule, 'repository')
651+ activities = store.find(
652+ GitActivity, GitActivity.repository_id == repository_id)
653+ self.assertEqual([], list(activities))
654
655
656 class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
657@@ -2273,11 +2282,11 @@
658 for _ in range(5)]
659 with person_logged_in(repository.owner):
660 self.assertEqual(rules, list(repository.rules))
661- repository.moveRule(rules[0], 4)
662+ repository.moveRule(rules[0], 4, repository.owner)
663 self.assertEqual(rules[1:] + [rules[0]], list(repository.rules))
664- repository.moveRule(rules[0], 0)
665+ repository.moveRule(rules[0], 0, repository.owner)
666 self.assertEqual(rules, list(repository.rules))
667- repository.moveRule(rules[2], 1)
668+ repository.moveRule(rules[2], 1, repository.owner)
669 self.assertEqual(
670 [rules[0], rules[2], rules[1], rules[3], rules[4]],
671 list(repository.rules))
672@@ -2285,7 +2294,9 @@
673 def test_moveRule_non_negative(self):
674 rule = self.factory.makeGitRule()
675 with person_logged_in(rule.repository.owner):
676- self.assertRaises(ValueError, rule.repository.moveRule, rule, -1)
677+ self.assertRaises(
678+ ValueError, rule.repository.moveRule,
679+ rule, -1, rule.repository.owner)
680
681 def test_grants(self):
682 repository = self.factory.makeGitRepository()
683
684=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
685--- lib/lp/code/model/tests/test_gitrule.py 2018-10-03 17:06:11 +0000
686+++ lib/lp/code/model/tests/test_gitrule.py 2018-10-12 16:44:41 +0000
687@@ -7,15 +7,23 @@
688
689 __metaclass__ = type
690
691+from lazr.lifecycle.event import ObjectModifiedEvent
692+from lazr.lifecycle.snapshot import Snapshot
693 from storm.store import Store
694 from testtools.matchers import (
695 Equals,
696 Is,
697+ MatchesDict,
698 MatchesSetwise,
699 MatchesStructure,
700 )
701+from zope.event import notify
702+from zope.interface import providedBy
703
704-from lp.code.enums import GitGranteeType
705+from lp.code.enums import (
706+ GitActivityType,
707+ GitGranteeType,
708+ )
709 from lp.code.interfaces.gitrule import (
710 IGitRule,
711 IGitRuleGrant,
712@@ -114,6 +122,93 @@
713 can_push=Is(False),
714 can_force_push=Is(True))))
715
716+ def test_activity_rule_added(self):
717+ owner = self.factory.makeTeam()
718+ member = self.factory.makePerson(member_of=[owner])
719+ repository = self.factory.makeGitRepository(owner=owner)
720+ self.factory.makeGitRule(repository=repository, creator=member)
721+ self.factory.makeGitRule(
722+ repository=repository, ref_pattern="refs/heads/stable/*",
723+ creator=member)
724+ self.assertThat(repository.getActivity().first(), MatchesStructure(
725+ repository=Equals(repository),
726+ changer=Equals(member),
727+ changee=Is(None),
728+ what_changed=Equals(GitActivityType.RULE_ADDED),
729+ old_value=Is(None),
730+ new_value=MatchesDict({
731+ "ref_pattern": Equals("refs/heads/stable/*"),
732+ "position": Equals(1),
733+ })))
734+
735+ def test_activity_rule_changed(self):
736+ owner = self.factory.makeTeam()
737+ member = self.factory.makePerson(member_of=[owner])
738+ repository = self.factory.makeGitRepository(owner=owner)
739+ rule = self.factory.makeGitRule(
740+ repository=repository, ref_pattern="refs/heads/*")
741+ rule_before_modification = Snapshot(rule, providing=providedBy(rule))
742+ with person_logged_in(member):
743+ rule.ref_pattern = "refs/heads/other/*"
744+ notify(ObjectModifiedEvent(
745+ rule, rule_before_modification, ["ref_pattern"]))
746+ self.assertThat(repository.getActivity().first(), MatchesStructure(
747+ repository=Equals(repository),
748+ changer=Equals(member),
749+ changee=Is(None),
750+ what_changed=Equals(GitActivityType.RULE_CHANGED),
751+ old_value=MatchesDict({
752+ "ref_pattern": Equals("refs/heads/*"),
753+ "position": Equals(0),
754+ }),
755+ new_value=MatchesDict({
756+ "ref_pattern": Equals("refs/heads/other/*"),
757+ "position": Equals(0),
758+ })))
759+
760+ def test_activity_rule_removed(self):
761+ owner = self.factory.makeTeam()
762+ member = self.factory.makePerson(member_of=[owner])
763+ repository = self.factory.makeGitRepository(owner=owner)
764+ rule = self.factory.makeGitRule(
765+ repository=repository, ref_pattern="refs/heads/*")
766+ with person_logged_in(member):
767+ rule.destroySelf(member)
768+ self.assertThat(repository.getActivity().first(), MatchesStructure(
769+ repository=Equals(repository),
770+ changer=Equals(member),
771+ changee=Is(None),
772+ what_changed=Equals(GitActivityType.RULE_REMOVED),
773+ old_value=MatchesDict({
774+ "ref_pattern": Equals("refs/heads/*"),
775+ "position": Equals(0),
776+ }),
777+ new_value=Is(None)))
778+
779+ def test_activity_rule_moved(self):
780+ owner = self.factory.makeTeam()
781+ member = self.factory.makePerson(member_of=[owner])
782+ repository = self.factory.makeGitRepository(owner=owner)
783+ self.factory.makeGitRule(
784+ repository=repository, ref_pattern="refs/heads/*")
785+ rule = self.factory.makeGitRule(
786+ repository=repository, ref_pattern="refs/heads/stable/*")
787+ with person_logged_in(member):
788+ repository.moveRule(rule, 0, member)
789+ self.assertThat(repository.getActivity().first(), MatchesStructure(
790+ repository=Equals(repository),
791+ changer=Equals(member),
792+ changee=Is(None),
793+ what_changed=Equals(GitActivityType.RULE_MOVED),
794+ old_value=MatchesDict({
795+ "ref_pattern": Equals("refs/heads/stable/*"),
796+ "position": Equals(1),
797+ }),
798+ new_value=MatchesDict({
799+ "ref_pattern": Equals("refs/heads/stable/*"),
800+ "position": Equals(0),
801+ })))
802+
803 def test_destroySelf(self):
804 repository = self.factory.makeGitRepository()
805 rules = [
806@@ -125,7 +220,7 @@
807 self.assertEqual([0, 1, 2, 3], [rule.position for rule in rules])
808 self.assertEqual(rules, list(repository.rules))
809 with person_logged_in(repository.owner):
810- rules[1].destroySelf()
811+ rules[1].destroySelf(repository.owner)
812 del rules[1]
813 self.assertEqual([0, 1, 2], [rule.position for rule in rules])
814 self.assertEqual(rules, list(repository.rules))
815@@ -136,7 +231,7 @@
816 grant = self.factory.makeGitRuleGrant(rule=rule)
817 self.assertEqual([grant], list(repository.grants))
818 with person_logged_in(repository.owner):
819- rule.destroySelf()
820+ rule.destroySelf(repository.owner)
821 self.assertEqual([], list(repository.grants))
822
823
824@@ -208,6 +303,84 @@
825 grantee.name, rule.repository.unique_name, rule.ref_pattern),
826 repr(grant))
827
828+ def test_activity_grant_added(self):
829+ owner = self.factory.makeTeam()
830+ member = self.factory.makePerson(member_of=[owner])
831+ repository = self.factory.makeGitRepository(owner=owner)
832+ grant = self.factory.makeGitRuleGrant(
833+ repository=repository, grantor=member, can_push=True)
834+ self.assertThat(repository.getActivity().first(), MatchesStructure(
835+ repository=Equals(repository),
836+ changer=Equals(member),
837+ changee=Equals(grant.grantee),
838+ what_changed=Equals(GitActivityType.GRANT_ADDED),
839+ old_value=Is(None),
840+ new_value=MatchesDict({
841+ "changee_type": Equals("Person"),
842+ "ref_pattern": Equals("refs/heads/*"),
843+ "can_create": Is(False),
844+ "can_push": Is(True),
845+ "can_force_push": Is(False),
846+ })))
847+
848+ def test_activity_grant_changed(self):
849+ owner = self.factory.makeTeam()
850+ member = self.factory.makePerson(member_of=[owner])
851+ repository = self.factory.makeGitRepository(owner=owner)
852+ grant = self.factory.makeGitRuleGrant(
853+ repository=repository, grantee=GitGranteeType.REPOSITORY_OWNER,
854+ can_create=True)
855+ grant_before_modification = Snapshot(
856+ grant, providing=providedBy(grant))
857+ with person_logged_in(member):
858+ grant.can_create = False
859+ grant.can_force_push = True
860+ notify(ObjectModifiedEvent(
861+ grant, grant_before_modification,
862+ ["can_create", "can_force_push"]))
863+ self.assertThat(repository.getActivity().first(), MatchesStructure(
864+ repository=Equals(repository),
865+ changer=Equals(member),
866+ changee=Is(None),
867+ what_changed=Equals(GitActivityType.GRANT_CHANGED),
868+ old_value=MatchesDict({
869+ "changee_type": Equals("Repository owner"),
870+ "ref_pattern": Equals("refs/heads/*"),
871+ "can_create": Is(True),
872+ "can_push": Is(False),
873+ "can_force_push": Is(False),
874+ }),
875+ new_value=MatchesDict({
876+ "changee_type": Equals("Repository owner"),
877+ "ref_pattern": Equals("refs/heads/*"),
878+ "can_create": Is(False),
879+ "can_push": Is(False),
880+ "can_force_push": Is(True),
881+ })))
882+
883+ def test_activity_grant_removed(self):
884+ owner = self.factory.makeTeam()
885+ member = self.factory.makePerson(member_of=[owner])
886+ repository = self.factory.makeGitRepository(owner=owner)
887+ grant = self.factory.makeGitRuleGrant(
888+ repository=repository, can_create=True, can_push=True)
889+ grantee = grant.grantee
890+ with person_logged_in(member):
891+ grant.destroySelf(member)
892+ self.assertThat(repository.getActivity().first(), MatchesStructure(
893+ repository=Equals(repository),
894+ changer=Equals(member),
895+ changee=Equals(grantee),
896+ what_changed=Equals(GitActivityType.GRANT_REMOVED),
897+ old_value=MatchesDict({
898+ "changee_type": Equals("Person"),
899+ "ref_pattern": Equals("refs/heads/*"),
900+ "can_create": Is(True),
901+ "can_push": Is(True),
902+ "can_force_push": Is(False),
903+ }),
904+ new_value=Is(None)))
905+
906 def test_destroySelf(self):
907 rule = self.factory.makeGitRule()
908 grants = [
909@@ -217,5 +390,5 @@
910 self.factory.makeGitRuleGrant(rule=rule, can_push=True),
911 ]
912 with person_logged_in(rule.repository.owner):
913- grants[1].destroySelf()
914+ grants[1].destroySelf(rule.repository.owner)
915 self.assertThat(rule.grants, MatchesSetwise(Equals(grants[0])))
916
917=== modified file 'lib/lp/security.py'
918--- lib/lp/security.py 2018-10-05 15:35:34 +0000
919+++ lib/lp/security.py 2018-10-12 16:44:41 +0000
920@@ -81,6 +81,7 @@
921 )
922 from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
923 from lp.code.interfaces.diff import IPreviewDiff
924+from lp.code.interfaces.gitactivity import IGitActivity
925 from lp.code.interfaces.gitcollection import IGitCollection
926 from lp.code.interfaces.gitref import IGitRef
927 from lp.code.interfaces.gitrepository import (
928@@ -2383,6 +2384,15 @@
929 super(EditGitRuleGrant, self).__init__(obj, obj.repository)
930
931
932+class ViewGitActivity(DelegatedAuthorization):
933+ """Anyone who can see a Git repository can see its activity logs."""
934+ permission = 'launchpad.View'
935+ usedfor = IGitActivity
936+
937+ def __init__(self, obj):
938+ super(ViewGitActivity, self).__init__(obj, obj.repository)
939+
940+
941 class AdminDistroSeriesTranslations(AuthorizationBase):
942 permission = 'launchpad.TranslationsAdmin'
943 usedfor = IDistroSeries
944
945=== modified file 'lib/lp/testing/factory.py'
946--- lib/lp/testing/factory.py 2018-10-05 15:35:34 +0000
947+++ lib/lp/testing/factory.py 2018-10-12 16:44:41 +0000
948@@ -1840,10 +1840,10 @@
949
950 def makeGitRuleGrant(self, rule=None, grantee=None, grantor=None,
951 can_create=False, can_push=False,
952- can_force_push=False):
953+ can_force_push=False, **rule_kwargs):
954 """Create a Git repository access grant."""
955 if rule is None:
956- rule = self.makeGitRule()
957+ rule = self.makeGitRule(**rule_kwargs)
958 if grantee is None:
959 grantee = self.makePerson()
960 if grantor is None: