Merge lp:~cjwatson/launchpad/git-permissions-notifications into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18796
Proposed branch: lp:~cjwatson/launchpad/git-permissions-notifications
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-activity-model
Diff against target: 610 lines (+301/-31)
10 files modified
database/schema/security.cfg (+1/-0)
lib/lp/code/adapters/gitrepository.py (+8/-3)
lib/lp/code/interfaces/gitactivity.py (+7/-1)
lib/lp/code/interfaces/gitrepository.py (+3/-1)
lib/lp/code/mail/branch.py (+26/-9)
lib/lp/code/model/gitactivity.py (+11/-0)
lib/lp/code/model/gitjob.py (+64/-5)
lib/lp/code/model/gitrepository.py (+4/-2)
lib/lp/code/model/tests/test_gitjob.py (+139/-1)
lib/lp/code/model/tests/test_gitrepository.py (+38/-9)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-permissions-notifications
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+354500@code.launchpad.net

Commit message

Send email notifications for GitRule and GitGrant changes.

Description of the change

We'll have to be careful to send the right ObjectModifiedEvents in the webservice API and the web UI (slightly fiddly as you have to send them both for any rules/grants and for the repository), but that should be manageable in later branches.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

My only concern is that this will probably send rule changes to all subscribers, not just those who can see the rules normally (the owner).

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2018-10-15 14:44:25 +0000
3+++ database/schema/security.cfg 2018-10-16 10:11:25 +0000
4@@ -2053,6 +2053,7 @@
5 public.branchmergeproposaljob = SELECT, INSERT
6 public.branchrevision = SELECT
7 public.branchsubscription = SELECT
8+public.codeimport = SELECT
9 public.codereviewinlinecomment = SELECT, INSERT
10 public.codereviewmessage = SELECT, INSERT
11 public.codereviewvote = SELECT, INSERT
12
13=== modified file 'lib/lp/code/adapters/gitrepository.py'
14--- lib/lp/code/adapters/gitrepository.py 2015-07-08 16:05:11 +0000
15+++ lib/lp/code/adapters/gitrepository.py 2018-10-16 10:11:25 +0000
16@@ -1,4 +1,4 @@
17-# Copyright 2015 Canonical Ltd. This software is licensed under the
18+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
19 # GNU Affero General Public License version 3 (see the file LICENSE).
20
21 """Components related to Git repositories."""
22@@ -27,12 +27,14 @@
23
24 interface = IGitRepository
25
26- def __init__(self, repository, user, name=None, git_identity=None):
27+ def __init__(self, repository, user, name=None, git_identity=None,
28+ activities=None):
29 self.repository = repository
30 self.user = user
31
32 self.name = name
33 self.git_identity = git_identity
34+ self.activities = activities
35
36 @classmethod
37 def construct(klass, old_repository, new_repository, user):
38@@ -43,10 +45,13 @@
39 """
40 delta = ObjectDelta(old_repository, new_repository)
41 delta.recordNewAndOld(klass.delta_values)
42- if delta.changes:
43+ activities = list(new_repository.getActivity(
44+ changed_after=old_repository.date_last_modified))
45+ if delta.changes or activities:
46 changes = delta.changes
47 changes["repository"] = new_repository
48 changes["user"] = user
49+ changes["activities"] = activities
50
51 return GitRepositoryDelta(**changes)
52 else:
53
54=== modified file 'lib/lp/code/interfaces/gitactivity.py'
55--- lib/lp/code/interfaces/gitactivity.py 2018-10-03 00:53:23 +0000
56+++ lib/lp/code/interfaces/gitactivity.py 2018-10-16 10:11:25 +0000
57@@ -12,7 +12,10 @@
58 ]
59
60 from lazr.restful.fields import Reference
61-from zope.interface import Interface
62+from zope.interface import (
63+ Attribute,
64+ Interface,
65+ )
66 from zope.schema import (
67 Choice,
68 Datetime,
69@@ -55,6 +58,9 @@
70 vocabulary="ValidPersonOrTeam",
71 description=_("The person or team that this change was applied to."))
72
73+ changee_description = Attribute(
74+ "A human-readable description of the changee.")
75+
76 what_changed = Choice(
77 title=_("What changed"), required=True, readonly=True,
78 vocabulary=GitActivityType,
79
80=== modified file 'lib/lp/code/interfaces/gitrepository.py'
81--- lib/lp/code/interfaces/gitrepository.py 2018-10-15 14:44:25 +0000
82+++ lib/lp/code/interfaces/gitrepository.py 2018-10-16 10:11:25 +0000
83@@ -622,9 +622,11 @@
84 :return: The diff as a binary string.
85 """
86
87- def getActivity():
88+ def getActivity(changed_after=None):
89 """Get activity log entries for this repository.
90
91+ :param changed_after: If supplied, only return entries for changes
92+ made after this date.
93 :return: A `ResultSet` of `IGitActivity`.
94 """
95
96
97=== modified file 'lib/lp/code/mail/branch.py'
98--- lib/lp/code/mail/branch.py 2015-09-01 17:10:46 +0000
99+++ lib/lp/code/mail/branch.py 2018-10-16 10:11:25 +0000
100@@ -1,12 +1,16 @@
101-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
102+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
103 # GNU Affero General Public License version 3 (see the file LICENSE).
104
105 """Email notifications related to branches."""
106
107 __metaclass__ = type
108
109-from zope.component import getUtility
110+from zope.component import (
111+ getAdapter,
112+ getUtility,
113+ )
114
115+from lp.app.interfaces.security import IAuthorization
116 from lp.code.adapters.branch import BranchDelta
117 from lp.code.adapters.gitrepository import GitRepositoryDelta
118 from lp.code.enums import (
119@@ -20,6 +24,7 @@
120 from lp.code.interfaces.gitref import IGitRef
121 from lp.registry.interfaces.person import IPerson
122 from lp.registry.interfaces.product import IProduct
123+from lp.registry.interfaces.role import IPersonRoles
124 from lp.services.config import config
125 from lp.services.mail import basemailer
126 from lp.services.mail.basemailer import BaseMailer
127@@ -163,13 +168,14 @@
128 app = 'code'
129
130 def __init__(self, subject, template_name, recipients, from_address,
131- delta=None, contents=None, diff=None, message_id=None,
132- revno=None, revision_id=None, notification_type=None,
133- **kwargs):
134+ delta=None, delta_for_editors=None, contents=None, diff=None,
135+ message_id=None, revno=None, revision_id=None,
136+ notification_type=None, **kwargs):
137 super(BranchMailer, self).__init__(
138 subject, template_name, recipients, from_address,
139 message_id=message_id, notification_type=notification_type)
140 self.delta_text = delta
141+ self.delta_for_editors_text = delta_for_editors
142 self.contents = contents
143 self.diff = diff
144 if diff is None:
145@@ -181,12 +187,16 @@
146 self.extra_template_params = kwargs
147
148 @classmethod
149- def forBranchModified(cls, branch, user, delta):
150+ def forBranchModified(cls, branch, user, delta, delta_for_editors=None):
151 """Construct a BranchMailer for mail about a branch modification.
152
153 :param branch: The branch that was modified.
154 :param user: The user making the change.
155- :param delta: an IBranchDelta representing the modification.
156+ :param delta: an IBranchDelta representing the modification as
157+ visible to people who cannot edit the branch.
158+ :param delta_for_editors: an IBranchDelta representing the
159+ notification as visible to people who can edit the branch. If
160+ None, `delta` is used for people who can edit the branch too.
161 :return: a BranchMailer.
162 """
163 recipients = branch.getNotificationRecipients()
164@@ -218,6 +228,7 @@
165 return cls(
166 '[Branch %(unique_name)s]', 'branch-modified.txt',
167 actual_recipients, from_address, delta=delta,
168+ delta_for_editors=delta_for_editors,
169 notification_type='branch-updated')
170
171 @classmethod
172@@ -288,8 +299,14 @@
173 params['diff'] = self.contents or ''
174 if not self._includeDiff(email):
175 params['diff'] += self._explainNotPresentDiff(email)
176- params['delta'] = (
177- self.delta_text if self.delta_text is not None else '')
178+ if self.delta_for_editors_text is not None:
179+ authz = getAdapter(branch, IAuthorization, 'launchpad.Edit')
180+ if authz.checkAuthenticated(IPersonRoles(recipient)):
181+ params['delta'] = self.delta_for_editors_text
182+ else:
183+ params['delta'] = self.delta_text or ''
184+ else:
185+ params['delta'] = self.delta_text or ''
186 params.update(self.extra_template_params)
187 return params
188
189
190=== modified file 'lib/lp/code/model/gitactivity.py'
191--- lib/lp/code/model/gitactivity.py 2018-09-11 16:39:57 +0000
192+++ lib/lp/code/model/gitactivity.py 2018-10-16 10:11:25 +0000
193@@ -72,6 +72,17 @@
194 self.old_value = old_value
195 self.new_value = new_value
196
197+ @property
198+ def changee_description(self):
199+ if self.changee is not None:
200+ return "~" + self.changee.name
201+ elif self.new_value is not None and "changee_type" in self.new_value:
202+ return self.new_value["changee_type"].lower()
203+ elif self.old_value is not None and "changee_type" in self.old_value:
204+ return self.old_value["changee_type"].lower()
205+ else:
206+ return None
207+
208
209 def _make_rule_value(rule, position=None):
210 return {
211
212=== modified file 'lib/lp/code/model/gitjob.py'
213--- lib/lp/code/model/gitjob.py 2015-10-01 14:45:57 +0000
214+++ lib/lp/code/model/gitjob.py 2018-10-16 10:11:25 +0000
215@@ -1,9 +1,10 @@
216-# Copyright 2015 Canonical Ltd. This software is licensed under the
217+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
218 # GNU Affero General Public License version 3 (see the file LICENSE).
219
220 __metaclass__ = type
221
222 __all__ = [
223+ 'describe_repository_delta',
224 'GitJob',
225 'GitJobType',
226 'GitRefScanJob',
227@@ -32,6 +33,7 @@
228 from zope.security.proxy import removeSecurityProxy
229
230 from lp.app.errors import NotFoundError
231+from lp.code.enums import GitActivityType
232 from lp.code.interfaces.githosting import IGitHostingClient
233 from lp.code.interfaces.gitjob import (
234 IGitJob,
235@@ -57,6 +59,7 @@
236 )
237 from lp.services.database.stormbase import StormBase
238 from lp.services.features import getFeatureFlag
239+from lp.services.helpers import english_list
240 from lp.services.job.model.job import (
241 EnumeratedSubclass,
242 Job,
243@@ -298,6 +301,57 @@
244 getUtility(IGitHostingClient).delete(self.repository_path, logger=log)
245
246
247+activity_descriptions = {
248+ GitActivityType.RULE_ADDED: "Added protected ref: {new[ref_pattern]}",
249+ GitActivityType.RULE_CHANGED: (
250+ "Changed protected ref: {old[ref_pattern]} => {new[ref_pattern]}"),
251+ GitActivityType.RULE_REMOVED: "Removed protected ref: {old[ref_pattern]}",
252+ GitActivityType.RULE_MOVED: (
253+ "Moved rule for protected ref {new[ref_pattern]}: "
254+ "position {old[position]} => {new[position]}"),
255+ GitActivityType.GRANT_ADDED: (
256+ "Added access for {changee} to {new[ref_pattern]}: {new_grants}"),
257+ GitActivityType.GRANT_CHANGED: (
258+ "Changed access for {changee} to {new[ref_pattern]}: "
259+ "{old_grants} => {new_grants}"),
260+ GitActivityType.GRANT_REMOVED: (
261+ "Removed access for {changee} to {old[ref_pattern]}: {old_grants}"),
262+ }
263+
264+
265+def describe_grants(activity_value):
266+ output = []
267+ if activity_value is not None:
268+ if activity_value.get("can_create"):
269+ output.append("create")
270+ if activity_value.get("can_push"):
271+ output.append("push")
272+ if activity_value.get("can_force_push"):
273+ output.append("force-push")
274+ return english_list(output)
275+
276+
277+def describe_repository_delta(repository_delta):
278+ output = text_delta(
279+ repository_delta, repository_delta.delta_values,
280+ repository_delta.new_values, repository_delta.interface).split("\n")
281+ if output and not output[-1]: # text_delta returned empty string
282+ output.pop()
283+ # Parts of the delta are only visible to people who can edit the
284+ # repository.
285+ output_for_editors = list(output)
286+ indent = " " * 4
287+ for activity in repository_delta.activities:
288+ if activity.what_changed in activity_descriptions:
289+ description = activity_descriptions[activity.what_changed].format(
290+ old=activity.old_value, new=activity.new_value,
291+ changee=activity.changee_description,
292+ old_grants=describe_grants(activity.old_value),
293+ new_grants=describe_grants(activity.new_value))
294+ output_for_editors.append(indent + description)
295+ return "\n".join(output), "\n".join(output_for_editors)
296+
297+
298 @implementer(IGitRepositoryModifiedMailJob)
299 @provider(IGitRepositoryModifiedMailJobSource)
300 class GitRepositoryModifiedMailJob(GitJobDerived):
301@@ -310,11 +364,11 @@
302 @classmethod
303 def create(cls, repository, user, repository_delta):
304 """See `IGitRepositoryModifiedMailJobSource`."""
305+ delta, delta_for_editors = describe_repository_delta(repository_delta)
306 metadata = {
307 "user": user.id,
308- "repository_delta": text_delta(
309- repository_delta, repository_delta.delta_values,
310- repository_delta.new_values, repository_delta.interface),
311+ "repository_delta": delta,
312+ "repository_delta_for_editors": delta_for_editors,
313 }
314 git_job = GitJob(repository, cls.class_job_type, metadata)
315 job = cls(git_job)
316@@ -329,10 +383,15 @@
317 def repository_delta(self):
318 return self.metadata["repository_delta"]
319
320+ @property
321+ def repository_delta_for_editors(self):
322+ return self.metadata["repository_delta_for_editors"]
323+
324 def getMailer(self):
325 """Return a `BranchMailer` for this job."""
326 return BranchMailer.forBranchModified(
327- self.repository, self.user, self.repository_delta)
328+ self.repository, self.user, self.repository_delta,
329+ delta_for_editors=self.repository_delta_for_editors)
330
331 def run(self):
332 """See `IGitRepositoryModifiedMailJob`."""
333
334=== modified file 'lib/lp/code/model/gitrepository.py'
335--- lib/lp/code/model/gitrepository.py 2018-10-15 14:44:25 +0000
336+++ lib/lp/code/model/gitrepository.py 2018-10-16 10:11:25 +0000
337@@ -240,7 +240,7 @@
338 """
339 if event.edited_fields:
340 repository.date_last_modified = UTC_NOW
341- send_git_repository_modified_notifications(repository, event)
342+ send_git_repository_modified_notifications(repository, event)
343
344
345 @implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)
346@@ -1208,9 +1208,11 @@
347 return self.grants.find(
348 GitRuleGrant.grantee_type == GitGranteeType.REPOSITORY_OWNER)
349
350- def getActivity(self):
351+ def getActivity(self, changed_after=None):
352 """See `IGitRepository`."""
353 clauses = [GitActivity.repository_id == self.id]
354+ if changed_after is not None:
355+ clauses.append(GitActivity.date_changed > changed_after)
356 return Store.of(self).find(GitActivity, *clauses).order_by(
357 Desc(GitActivity.date_changed), Desc(GitActivity.id))
358
359
360=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
361--- lib/lp/code/model/tests/test_gitjob.py 2018-09-27 13:50:06 +0000
362+++ lib/lp/code/model/tests/test_gitjob.py 2018-10-16 10:11:25 +0000
363@@ -13,6 +13,8 @@
364 )
365 import hashlib
366
367+from lazr.lifecycle.event import ObjectModifiedEvent
368+from lazr.lifecycle.snapshot import Snapshot
369 import pytz
370 from testtools.matchers import (
371 ContainsDict,
372@@ -21,9 +23,16 @@
373 MatchesSetwise,
374 MatchesStructure,
375 )
376+import transaction
377+from zope.event import notify
378+from zope.interface import providedBy
379 from zope.security.proxy import removeSecurityProxy
380
381-from lp.code.enums import GitObjectType
382+from lp.code.adapters.gitrepository import GitRepositoryDelta
383+from lp.code.enums import (
384+ GitGranteeType,
385+ GitObjectType,
386+ )
387 from lp.code.interfaces.branchmergeproposal import (
388 BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG,
389 )
390@@ -33,6 +42,7 @@
391 IReclaimGitRepositorySpaceJob,
392 )
393 from lp.code.model.gitjob import (
394+ describe_repository_delta,
395 GitJob,
396 GitJobDerived,
397 GitJobType,
398@@ -47,6 +57,7 @@
399 from lp.services.utils import seconds_since_epoch
400 from lp.services.webapp import canonical_url
401 from lp.testing import (
402+ person_logged_in,
403 TestCaseWithFactory,
404 time_counter,
405 )
406@@ -338,5 +349,132 @@
407 self.assertEqual([(path,)], hosting_fixture.delete.extract_args())
408
409
410+class TestDescribeRepositoryDelta(TestCaseWithFactory):
411+ """Tests for `describe_repository_delta`."""
412+
413+ layer = ZopelessDatabaseLayer
414+
415+ def assertDeltaDescriptionEqual(self, expected, expected_for_editors,
416+ snapshot, repository):
417+ repository_delta = GitRepositoryDelta.construct(
418+ snapshot, repository, repository.owner)
419+ delta, delta_for_editors = describe_repository_delta(repository_delta)
420+ self.assertEqual(
421+ "\n".join(" %s" % line for line in expected), delta)
422+ self.assertEqual(
423+ "\n".join(" %s" % line for line in expected_for_editors),
424+ delta_for_editors)
425+
426+ def test_change_basic_properties(self):
427+ repository = self.factory.makeGitRepository(name="foo")
428+ transaction.commit()
429+ snapshot = Snapshot(repository, providing=providedBy(repository))
430+ with person_logged_in(repository.owner):
431+ repository.setName("bar", repository.owner)
432+ expected = [
433+ "Name: foo => bar",
434+ "Git identity: lp:~{person}/{project}/+git/foo => "
435+ "lp:~{person}/{project}/+git/bar".format(
436+ person=repository.owner.name, project=repository.target.name),
437+ ]
438+ self.assertDeltaDescriptionEqual(
439+ expected, expected, snapshot, repository)
440+
441+ def test_add_rule(self):
442+ repository = self.factory.makeGitRepository()
443+ transaction.commit()
444+ snapshot = Snapshot(repository, providing=providedBy(repository))
445+ with person_logged_in(repository.owner):
446+ repository.addRule("refs/heads/*", repository.owner)
447+ self.assertDeltaDescriptionEqual(
448+ [], ["Added protected ref: refs/heads/*"], snapshot, repository)
449+
450+ def test_change_rule(self):
451+ repository = self.factory.makeGitRepository()
452+ rule = self.factory.makeGitRule(
453+ repository=repository, ref_pattern="refs/heads/foo")
454+ transaction.commit()
455+ snapshot = Snapshot(repository, providing=providedBy(repository))
456+ rule_snapshot = Snapshot(rule, providing=providedBy(rule))
457+ with person_logged_in(repository.owner):
458+ rule.ref_pattern = "refs/heads/bar"
459+ notify(ObjectModifiedEvent(rule, rule_snapshot, ["ref_pattern"]))
460+ self.assertDeltaDescriptionEqual(
461+ [], ["Changed protected ref: refs/heads/foo => refs/heads/bar"],
462+ snapshot, repository)
463+
464+ def test_remove_rule(self):
465+ repository = self.factory.makeGitRepository()
466+ rule = self.factory.makeGitRule(
467+ repository=repository, ref_pattern="refs/heads/*")
468+ transaction.commit()
469+ snapshot = Snapshot(repository, providing=providedBy(repository))
470+ with person_logged_in(repository.owner):
471+ rule.destroySelf(repository.owner)
472+ self.assertDeltaDescriptionEqual(
473+ [], ["Removed protected ref: refs/heads/*"], snapshot, repository)
474+
475+ def test_move_rule(self):
476+ repository = self.factory.makeGitRepository()
477+ rule = self.factory.makeGitRule(
478+ repository=repository, ref_pattern="refs/heads/*")
479+ self.factory.makeGitRule(
480+ repository=repository, ref_pattern="refs/heads/stable/*")
481+ transaction.commit()
482+ snapshot = Snapshot(repository, providing=providedBy(repository))
483+ with person_logged_in(repository.owner):
484+ repository.moveRule(rule, 1, repository.owner)
485+ self.assertDeltaDescriptionEqual(
486+ [], ["Moved rule for protected ref refs/heads/*: position 0 => 1"],
487+ snapshot, repository)
488+
489+ def test_add_grant(self):
490+ repository = self.factory.makeGitRepository()
491+ rule = self.factory.makeGitRule(
492+ repository=repository, ref_pattern="refs/heads/*")
493+ transaction.commit()
494+ snapshot = Snapshot(repository, providing=providedBy(repository))
495+ with person_logged_in(repository.owner):
496+ rule.addGrant(
497+ GitGranteeType.REPOSITORY_OWNER, repository.owner,
498+ can_create=True, can_push=True, can_force_push=True)
499+ self.assertDeltaDescriptionEqual(
500+ [],
501+ ["Added access for repository owner to refs/heads/*: "
502+ "create, push, and force-push"],
503+ snapshot, repository)
504+
505+ def test_change_grant(self):
506+ repository = self.factory.makeGitRepository()
507+ grant = self.factory.makeGitRuleGrant(
508+ repository=repository, ref_pattern="refs/heads/*",
509+ can_create=True)
510+ transaction.commit()
511+ snapshot = Snapshot(repository, providing=providedBy(repository))
512+ grant_snapshot = Snapshot(grant, providing=providedBy(grant))
513+ with person_logged_in(repository.owner):
514+ grant.can_push = True
515+ notify(ObjectModifiedEvent(grant, grant_snapshot, ["can_push"]))
516+ self.assertDeltaDescriptionEqual(
517+ [],
518+ ["Changed access for ~{grantee} to refs/heads/*: "
519+ "create => create and push".format(grantee=grant.grantee.name)],
520+ snapshot, repository)
521+
522+ def test_remove_grant(self):
523+ repository = self.factory.makeGitRepository()
524+ grant = self.factory.makeGitRuleGrant(
525+ repository=repository, ref_pattern="refs/heads/*",
526+ grantee=GitGranteeType.REPOSITORY_OWNER, can_push=True)
527+ transaction.commit()
528+ snapshot = Snapshot(repository, providing=providedBy(repository))
529+ with person_logged_in(repository.owner):
530+ grant.destroySelf(repository.owner)
531+ self.assertDeltaDescriptionEqual(
532+ [],
533+ ["Removed access for repository owner to refs/heads/*: push"],
534+ snapshot, repository)
535+
536+
537 # XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too,
538 # but that isn't feasible until we have a proper turnip fixture.
539
540=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
541--- lib/lp/code/model/tests/test_gitrepository.py 2018-10-15 14:44:25 +0000
542+++ lib/lp/code/model/tests/test_gitrepository.py 2018-10-16 10:11:25 +0000
543@@ -981,6 +981,12 @@
544 # Attribute modifications send mail to subscribers.
545 self.assertEqual(0, len(stub.test_emails))
546 repository = self.factory.makeGitRepository(name="foo")
547+ subscriber = self.factory.makePerson()
548+ owner_address = (
549+ removeSecurityProxy(repository.owner.preferredemail).email)
550+ subscriber_address = (
551+ removeSecurityProxy(subscriber.preferredemail).email)
552+ transaction.commit()
553 repository_before_modification = Snapshot(
554 repository, providing=providedBy(repository))
555 with person_logged_in(repository.owner):
556@@ -990,22 +996,45 @@
557 BranchSubscriptionDiffSize.NODIFF,
558 CodeReviewNotificationLevel.NOEMAIL,
559 repository.owner)
560+ repository.subscribe(
561+ subscriber,
562+ BranchSubscriptionNotificationLevel.ATTRIBUTEONLY,
563+ BranchSubscriptionDiffSize.NODIFF,
564+ CodeReviewNotificationLevel.NOEMAIL,
565+ repository.owner)
566 repository.setName("bar", repository.owner)
567+ repository.addRule("refs/heads/stable/*", repository.owner)
568 notify(ObjectModifiedEvent(
569 repository, repository_before_modification, ["name"],
570 user=repository.owner))
571 with dbuser(config.IGitRepositoryModifiedMailJobSource.dbuser):
572 JobRunner.fromReady(
573 getUtility(IGitRepositoryModifiedMailJobSource)).runAll()
574- self.assertEqual(1, len(stub.test_emails))
575- message = email.message_from_string(stub.test_emails[0][2])
576- body = message.get_payload(decode=True)
577- self.assertIn("Name: foo => bar\n", body)
578- self.assertIn(
579- "Git identity: lp:~{person}/{project}/+git/foo => "
580- "lp:~{person}/{project}/+git/bar\n".format(
581- person=repository.owner.name, project=repository.target.name),
582- body)
583+ bodies_by_recipient = {}
584+ for from_addr, to_addrs, message in stub.test_emails:
585+ body = email.message_from_string(message).get_payload(decode=True)
586+ for to_addr in to_addrs:
587+ bodies_by_recipient[to_addr] = body
588+ # Both the owner and the unprivileged subscriber receive email.
589+ self.assertContentEqual(
590+ [owner_address, subscriber_address], bodies_by_recipient.keys())
591+ # The owner receives a message including details of permission
592+ # changes.
593+ self.assertEqual(
594+ " Name: foo => bar\n"
595+ " Git identity: lp:~{person}/{project}/+git/foo => "
596+ "lp:~{person}/{project}/+git/bar\n"
597+ " Added protected ref: refs/heads/stable/*".format(
598+ person=repository.owner.name, project=repository.target.name),
599+ bodies_by_recipient[owner_address].split("\n\n--\n")[0])
600+ # The unprivileged subscriber receives a message omitting details of
601+ # permission changes.
602+ self.assertEqual(
603+ " Name: foo => bar\n"
604+ " Git identity: lp:~{person}/{project}/+git/foo => "
605+ "lp:~{person}/{project}/+git/bar".format(
606+ person=repository.owner.name, project=repository.target.name),
607+ bodies_by_recipient[subscriber_address].split("\n\n--\n")[0])
608
609 # XXX cjwatson 2015-02-04: This will need to be expanded once Launchpad
610 # actually notices any interesting kind of repository modifications.