Merge lp:~cjwatson/launchpad/consistent-bmp-events into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 17807
Proposed branch: lp:~cjwatson/launchpad/consistent-bmp-events
Merge into: lp:launchpad
Diff against target: 863 lines (+268/-96)
17 files modified
lib/lp/code/browser/branchmergeproposal.py (+1/-12)
lib/lp/code/browser/tests/test_branchmergeproposal.py (+140/-6)
lib/lp/code/configure.zcml (+2/-2)
lib/lp/code/event/branchmergeproposal.py (+1/-14)
lib/lp/code/interfaces/event.py (+0/-10)
lib/lp/code/mail/codehandler.py (+9/-7)
lib/lp/code/mail/tests/test_codehandler.py (+21/-1)
lib/lp/code/model/branchmergeproposal.py (+0/-4)
lib/lp/code/model/branchmergeproposaljob.py (+1/-0)
lib/lp/code/model/tests/test_branchmergeproposal.py (+7/-7)
lib/lp/code/model/tests/test_codereviewcomment.py (+3/-2)
lib/lp/code/model/tests/test_codereviewkarma.py (+12/-5)
lib/lp/code/model/tests/test_gitrepository.py (+17/-3)
lib/lp/code/subscribers/branchmergeproposal.py (+13/-8)
lib/lp/code/subscribers/karma.py (+15/-6)
lib/lp/codehosting/scanner/tests/test_mergedetection.py (+10/-2)
lib/lp/testing/__init__.py (+16/-7)
To merge this branch: bzr merge lp:~cjwatson/launchpad/consistent-bmp-events
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+273325@code.launchpad.net

Commit message

Remove BranchMergeProposalStatusChangeEvent and consistently generate ObjectModifiedEvent for all changes to merge proposal attributes.

Description of the change

I'd like to hook merge proposal webhooks into the Zope events that are already generated for several kinds of changes to merge proposals, but this is complicated by unnecessarily-chaotic conditions for when those events are generated. This removes the special-purpose BranchMergeProposalStatusChangeEvent, and instead simply consistently generates ObjectModifiedEvent for all changes to merge proposal attributes.

To get this right, I exhaustively checked all the possible calling paths to BranchMergeProposal._transitionToState. A couple of them (BranchMergeProposal:+resubmit and CodeHandler) failed to be consistent with other paths in terms of whether they generated ObjectModifiedEvent, which wasn't a practical problem before but would be now; and most of the rest were untested. I fixed all this up. The one relevant webservice entry point (BranchMergeProposal.setStatus) of course gets this right automatically thanks to lazr.restful.

BranchMergeProposalNeedsReviewEvent continues to exist even though it's sometimes accompanied by an almost equivalent ObjectModifiedEvent, because it can also be generated when a merge proposal is created.

This also has the useful side-effect of sending mail notifications for transitions to SUPERSEDED, per bug 716169. I made this consistent and added tests for it.

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) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/branchmergeproposal.py'
2--- lib/lp/code/browser/branchmergeproposal.py 2015-07-21 16:30:15 +0000
3+++ lib/lp/code/browser/branchmergeproposal.py 2015-10-06 15:19:46 +0000
4@@ -161,18 +161,6 @@
5 return decorator
6
7
8-def update_and_notify(func):
9- """Decorate an action to update from a form and send a notification."""
10-
11- @notify
12- def decorator(view, action, data):
13- result = func(view, action, data)
14- form.applyChanges(
15- view.context, view.form_fields, data, view.adapters)
16- return result
17- return decorator
18-
19-
20 class BranchMergeCandidateView(LaunchpadView):
21 """Provides a small fragment of landing targets"""
22
23@@ -1028,6 +1016,7 @@
24 return dict(item for item in items if item[1] is not UNSET)
25
26 @action('Resubmit', name='resubmit')
27+ @notify
28 def resubmit_action(self, action, data):
29 """Resubmit this proposal."""
30 try:
31
32=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
33--- lib/lp/code/browser/tests/test_branchmergeproposal.py 2015-10-01 10:25:19 +0000
34+++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2015-10-06 15:19:46 +0000
35@@ -12,6 +12,7 @@
36 )
37 from difflib import unified_diff
38
39+from lazr.lifecycle.event import ObjectModifiedEvent
40 from lazr.restful.interfaces import IJSONRequestCache
41 import pytz
42 import simplejson
43@@ -20,11 +21,16 @@
44 Tag,
45 )
46 from testtools.matchers import (
47+ MatchesListwise,
48 MatchesRegex,
49+ MatchesStructure,
50 Not,
51 )
52 import transaction
53-from zope.component import getMultiAdapter
54+from zope.component import (
55+ getMultiAdapter,
56+ getUtility,
57+ )
58 from zope.security.interfaces import Unauthorized
59 from zope.security.proxy import removeSecurityProxy
60
61@@ -47,6 +53,11 @@
62 BranchMergeProposalStatus,
63 CodeReviewVote,
64 )
65+from lp.code.interfaces.branchmergeproposal import (
66+ IBranchMergeProposal,
67+ IMergeProposalNeedsReviewEmailJobSource,
68+ IMergeProposalUpdatedEmailJobSource,
69+ )
70 from lp.code.model.diff import PreviewDiff
71 from lp.code.tests.helpers import (
72 add_revision_to_branch,
73@@ -66,6 +77,7 @@
74 from lp.services.webapp.servers import LaunchpadTestRequest
75 from lp.testing import (
76 BrowserTestCase,
77+ EventRecorder,
78 feature_flags,
79 login_person,
80 monkey_patch,
81@@ -168,6 +180,27 @@
82 browser = self.getViewBrowser(bmp.merge_source, '+index')
83 self.assertThat(browser.contents, HTMLContains(revision_number))
84
85+ def test_notifies_modification(self):
86+ bmp = self.makeBranchMergeProposal()
87+ registrant = bmp.registrant
88+ login_person(registrant)
89+ browser = self.getViewBrowser(bmp, '+merged', user=bmp.registrant)
90+ browser.getControl(self.merged_revision_text).value = str(
91+ self.arbitrary_revisions[2])
92+ with EventRecorder(propagate=True) as recorder:
93+ browser.getControl('Mark as Merged').click()
94+ login_person(registrant)
95+ events = [
96+ event for event in recorder.events
97+ if isinstance(event, ObjectModifiedEvent) and
98+ IBranchMergeProposal.providedBy(event.object)]
99+ self.assertThat(events, MatchesListwise([
100+ MatchesStructure(
101+ object_before_modification=MatchesStructure.byEquality(
102+ queue_status=BranchMergeProposalStatus.WORK_IN_PROGRESS),
103+ object=MatchesStructure.byEquality(
104+ queue_status=BranchMergeProposalStatus.MERGED))]))
105+
106
107 class TestBranchMergeProposalMergedViewBzr(
108 TestBranchMergeProposalMergedViewMixin, BrowserTestCase):
109@@ -918,6 +951,50 @@
110 simplejson.loads(view.form_result))
111
112
113+class TestBranchMergeProposalRequestReviewViewMixin:
114+ """Test `BranchMergeProposalRequestReviewView`."""
115+
116+ layer = DatabaseFunctionalLayer
117+
118+ def test_notifies_modification(self):
119+ bmp = self.makeBranchMergeProposal()
120+ registrant = bmp.registrant
121+ self.factory.makePerson(name='test-reviewer')
122+ login_person(registrant)
123+ browser = self.getViewBrowser(
124+ bmp, '+request-review', user=bmp.registrant)
125+ browser.getControl('Reviewer').value = 'test-reviewer'
126+ with EventRecorder(propagate=True) as recorder:
127+ browser.getControl('Request Review').click()
128+ login_person(registrant)
129+ events = [
130+ event for event in recorder.events
131+ if isinstance(event, ObjectModifiedEvent) and
132+ IBranchMergeProposal.providedBy(event.object)]
133+ self.assertThat(events, MatchesListwise([
134+ MatchesStructure(
135+ object_before_modification=MatchesStructure.byEquality(
136+ queue_status=BranchMergeProposalStatus.WORK_IN_PROGRESS),
137+ object=MatchesStructure.byEquality(
138+ queue_status=BranchMergeProposalStatus.NEEDS_REVIEW))]))
139+
140+
141+class TestBranchMergeProposalRequestReviewViewBzr(
142+ TestBranchMergeProposalRequestReviewViewMixin, BrowserTestCase):
143+ """Test `BranchMergeProposalRequestReviewView` for Bazaar."""
144+
145+ def makeBranchMergeProposal(self):
146+ return self.factory.makeBranchMergeProposal()
147+
148+
149+class TestBranchMergeProposalRequestReviewViewGit(
150+ TestBranchMergeProposalRequestReviewViewMixin, BrowserTestCase):
151+ """Test `BranchMergeProposalRequestReviewView` for Git."""
152+
153+ def makeBranchMergeProposal(self):
154+ return self.factory.makeBranchMergeProposalForGit()
155+
156+
157 class TestBranchMergeProposalResubmitViewMixin:
158 """Test BranchMergeProposalResubmitView."""
159
160@@ -998,13 +1075,54 @@
161 ' <a href=.*>a similar merge proposal</a> is already active.'))
162 self.assertEqual(BrowserNotificationLevel.ERROR, notification.level)
163
164+ def test_notifies_modification(self):
165+ view = self.createView()
166+ with EventRecorder(propagate=True) as recorder:
167+ self.resubmitDefault(view)
168+ events = [
169+ event for event in recorder.events
170+ if isinstance(event, ObjectModifiedEvent) and
171+ IBranchMergeProposal.providedBy(event.object)]
172+ self.assertThat(events, MatchesListwise([
173+ MatchesStructure(
174+ object_before_modification=MatchesStructure.byEquality(
175+ queue_status=BranchMergeProposalStatus.WORK_IN_PROGRESS),
176+ object=MatchesStructure.byEquality(
177+ queue_status=BranchMergeProposalStatus.SUPERSEDED))]))
178+
179+ def test_mail_jobs(self):
180+ """Resubmitting sends mail about the changed old MP and the new MP."""
181+ view = self.createView()
182+ new_proposal = self.resubmitDefault(view)
183+ updated_job_source = getUtility(IMergeProposalUpdatedEmailJobSource)
184+ [updated_job] = list(
185+ removeSecurityProxy(updated_job_source).iterReady())
186+ self.assertThat(updated_job, MatchesStructure.byEquality(
187+ branch_merge_proposal=view.context,
188+ metadata={
189+ 'delta_text': ' Status: Work in progress => Superseded',
190+ 'editor': view.context.registrant.name,
191+ }))
192+ needs_review_job_source = getUtility(
193+ IMergeProposalNeedsReviewEmailJobSource)
194+ [needs_review_job] = list(
195+ removeSecurityProxy(needs_review_job_source).iterReady())
196+ self.assertThat(needs_review_job, MatchesStructure.byEquality(
197+ branch_merge_proposal=new_proposal,
198+ metadata={}))
199+
200
201 class TestBranchMergeProposalResubmitViewBzr(
202 TestBranchMergeProposalResubmitViewMixin, TestCaseWithFactory):
203 """Test BranchMergeProposalResubmitView for Bazaar."""
204
205 def _makeBranchMergeProposal(self):
206- return self.factory.makeBranchMergeProposal()
207+ bmp = self.factory.makeBranchMergeProposal()
208+ # Pretend to have a revision so that
209+ # BranchMergeProposalJobDerived.iterReady will consider this
210+ # proposal.
211+ removeSecurityProxy(bmp.source_branch).revision_count = 1
212+ return bmp
213
214 @staticmethod
215 def _getFormValues(source_branch, target_branch, prerequisite_branch,
216@@ -1366,8 +1484,8 @@
217 self.assertThat(browser.contents, HTMLContains(expected_meta))
218
219
220-class TestBranchMergeProposalChangeStatusOptions(TestCaseWithFactory):
221- """Test the status vocabulary generated for then +edit-status view."""
222+class TestBranchMergeProposalChangeStatusView(TestCaseWithFactory):
223+ """Test the +edit-status view."""
224
225 layer = DatabaseFunctionalLayer
226
227@@ -1378,10 +1496,10 @@
228 self.proposal = self.factory.makeBranchMergeProposal(
229 registrant=self.user)
230
231- def _createView(self):
232+ def _createView(self, form=None):
233 # Construct the view and initialize it.
234 view = BranchMergeProposalChangeStatusView(
235- self.proposal, LaunchpadTestRequest())
236+ self.proposal, LaunchpadTestRequest(form=form))
237 view.initialize()
238 return view
239
240@@ -1454,6 +1572,22 @@
241 self.assertAllStatusesAvailable(
242 user=self.proposal.target_branch.owner)
243
244+ def test_notifies_modification(self):
245+ view = self._createView(form={'revno': '1'})
246+ with EventRecorder(propagate=True) as recorder:
247+ view.update_action.success(
248+ {'queue_status': BranchMergeProposalStatus.NEEDS_REVIEW})
249+ events = [
250+ event for event in recorder.events
251+ if isinstance(event, ObjectModifiedEvent) and
252+ IBranchMergeProposal.providedBy(event.object)]
253+ self.assertThat(events, MatchesListwise([
254+ MatchesStructure(
255+ object_before_modification=MatchesStructure.byEquality(
256+ queue_status=BranchMergeProposalStatus.WORK_IN_PROGRESS),
257+ object=MatchesStructure.byEquality(
258+ queue_status=BranchMergeProposalStatus.NEEDS_REVIEW))]))
259+
260
261 class TestCommentAttachmentRendering(TestCaseWithFactory):
262 """Test diff attachments are rendered correctly."""
263
264=== modified file 'lib/lp/code/configure.zcml'
265--- lib/lp/code/configure.zcml 2015-09-24 13:44:02 +0000
266+++ lib/lp/code/configure.zcml 2015-10-06 15:19:46 +0000
267@@ -295,8 +295,8 @@
268 handler="lp.code.subscribers.karma.branch_merge_proposed"/>
269 <subscriber
270 for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal
271- lp.code.interfaces.event.IBranchMergeProposalStatusChangeEvent"
272- handler="lp.code.subscribers.karma.branch_merge_status_changed"/>
273+ lazr.lifecycle.interfaces.IObjectModifiedEvent"
274+ handler="lp.code.subscribers.karma.branch_merge_modified"/>
275
276 <!-- hierarchy -->
277
278
279=== modified file 'lib/lp/code/event/branchmergeproposal.py'
280--- lib/lp/code/event/branchmergeproposal.py 2015-07-08 16:05:11 +0000
281+++ lib/lp/code/event/branchmergeproposal.py 2015-10-06 15:19:46 +0000
282@@ -1,4 +1,4 @@
283-# Copyright 2009 Canonical Ltd. This software is licensed under the
284+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
285 # GNU Affero General Public License version 3 (see the file LICENSE).
286
287 """Event implementation classes for branch merge proposal events."""
288@@ -6,7 +6,6 @@
289 __metaclass__ = type
290 __all__ = [
291 'BranchMergeProposalNeedsReviewEvent',
292- 'BranchMergeProposalStatusChangeEvent',
293 'NewBranchMergeProposalEvent',
294 'NewCodeReviewCommentEvent',
295 'ReviewerNominatedEvent',
296@@ -17,24 +16,12 @@
297
298 from lp.code.interfaces.event import (
299 IBranchMergeProposalNeedsReviewEvent,
300- IBranchMergeProposalStatusChangeEvent,
301 INewBranchMergeProposalEvent,
302 INewCodeReviewCommentEvent,
303 IReviewerNominatedEvent,
304 )
305
306
307-@implementer(IBranchMergeProposalStatusChangeEvent)
308-class BranchMergeProposalStatusChangeEvent(ObjectEvent):
309- """See `IBranchMergeProposalStatusChangeEvent`."""
310-
311- def __init__(self, proposal, user, from_state, to_state):
312- ObjectEvent.__init__(self, proposal)
313- self.user = user
314- self.from_state = from_state
315- self.to_state = to_state
316-
317-
318 @implementer(INewBranchMergeProposalEvent)
319 class NewBranchMergeProposalEvent(ObjectEvent):
320 """A new merge has been proposed."""
321
322=== modified file 'lib/lp/code/interfaces/event.py'
323--- lib/lp/code/interfaces/event.py 2015-06-06 08:49:54 +0000
324+++ lib/lp/code/interfaces/event.py 2015-10-06 15:19:46 +0000
325@@ -5,7 +5,6 @@
326
327 __metaclass__ = type
328 __all__ = [
329- 'IBranchMergeProposalStatusChangeEvent',
330 'IBranchMergeProposalNeedsReviewEvent',
331 'IGitRefsUpdatedEvent',
332 'INewBranchMergeProposalEvent',
333@@ -15,15 +14,6 @@
334
335
336 from zope.component.interfaces import IObjectEvent
337-from zope.interface import Attribute
338-
339-
340-class IBranchMergeProposalStatusChangeEvent(IObjectEvent):
341- """A merge proposal has changed state."""
342-
343- user = Attribute("The user who updated the proposal.")
344- from_state = Attribute("The previous queue_status.")
345- to_state = Attribute("The updated queue_status.")
346
347
348 class INewBranchMergeProposalEvent(IObjectEvent):
349
350=== modified file 'lib/lp/code/mail/codehandler.py'
351--- lib/lp/code/mail/codehandler.py 2015-09-28 17:38:45 +0000
352+++ lib/lp/code/mail/codehandler.py 2015-10-06 15:19:46 +0000
353@@ -1,4 +1,4 @@
354-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
355+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
356 # GNU Affero General Public License version 3 (see the file LICENSE).
357
358 __metaclass__ = type
359@@ -14,6 +14,7 @@
360 from zope.interface import implementer
361 from zope.security.interfaces import Unauthorized
362
363+from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
364 from lp.code.enums import CodeReviewVote
365 from lp.code.errors import UserNotBranchReviewer
366 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposalGetter
367@@ -258,12 +259,13 @@
368 def processCommands(self, context, commands):
369 """Process the various merge proposal commands against the context."""
370 processing_errors = []
371-
372- for command in commands:
373- try:
374- command.execute(context)
375- except EmailProcessingError as error:
376- processing_errors.append((error, command))
377+ with BranchMergeProposalNoPreviewDiffDelta.monitor(
378+ context.merge_proposal):
379+ for command in commands:
380+ try:
381+ command.execute(context)
382+ except EmailProcessingError as error:
383+ processing_errors.append((error, command))
384
385 if len(processing_errors) > 0:
386 errors, commands = zip(*processing_errors)
387
388=== modified file 'lib/lp/code/mail/tests/test_codehandler.py'
389--- lib/lp/code/mail/tests/test_codehandler.py 2015-07-21 09:04:01 +0000
390+++ lib/lp/code/mail/tests/test_codehandler.py 2015-10-06 15:19:46 +0000
391@@ -1,4 +1,4 @@
392-# Copyright 2009-2014 Canonical Ltd. This software is licensed under the
393+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
394 # GNU Affero General Public License version 3 (see the file LICENSE).
395
396 """Testing the CodeHandler."""
397@@ -7,6 +7,7 @@
398
399 from textwrap import dedent
400
401+from lazr.lifecycle.event import ObjectModifiedEvent
402 from storm.store import Store
403 import transaction
404 from zope.security.management import setSecurityPolicy
405@@ -17,6 +18,7 @@
406 CodeReviewNotificationLevel,
407 CodeReviewVote,
408 )
409+from lp.code.event.branchmergeproposal import NewCodeReviewCommentEvent
410 from lp.code.mail.codehandler import (
411 AddReviewerEmailCommand,
412 CodeEmailCommands,
413@@ -425,6 +427,24 @@
414 mail['from'])
415 self.assertEqual(0, bmp.all_comments.count())
416
417+ def test_notifies_modification(self):
418+ """Changes to the merge proposal itself trigger events."""
419+ mail = self.factory.makeSignedMessage(body=' merge approved')
420+ bmp = self.factory.makeBranchMergeProposal()
421+ email_addr = bmp.address
422+ switch_dbuser(config.processmail.dbuser)
423+ login_person(bmp.merge_target.owner)
424+ _, events = self.assertNotifies(
425+ [ObjectModifiedEvent, NewCodeReviewCommentEvent], False,
426+ self.code_handler.process, mail, email_addr, None)
427+ self.assertEqual(bmp, events[0].object)
428+ self.assertEqual(
429+ BranchMergeProposalStatus.WORK_IN_PROGRESS,
430+ events[0].object_before_modification.queue_status)
431+ self.assertEqual(
432+ BranchMergeProposalStatus.CODE_APPROVED,
433+ events[0].object.queue_status)
434+
435
436 class TestVoteEmailCommand(TestCase):
437 """Test the vote and tag processing of the VoteEmailCommand."""
438
439=== modified file 'lib/lp/code/model/branchmergeproposal.py'
440--- lib/lp/code/model/branchmergeproposal.py 2015-07-08 16:05:11 +0000
441+++ lib/lp/code/model/branchmergeproposal.py 2015-10-06 15:19:46 +0000
442@@ -58,7 +58,6 @@
443 )
444 from lp.code.event.branchmergeproposal import (
445 BranchMergeProposalNeedsReviewEvent,
446- BranchMergeProposalStatusChangeEvent,
447 NewCodeReviewCommentEvent,
448 ReviewerNominatedEvent,
449 )
450@@ -577,7 +576,6 @@
451 _date_reviewed=None):
452 """Set the proposal to next_state."""
453 # Check the reviewer can review the code for the target branch.
454- old_state = self.queue_status
455 if not self.merge_target.isPersonTrustedReviewer(reviewer):
456 raise UserNotBranchReviewer
457 # Check the current state of the proposal.
458@@ -589,8 +587,6 @@
459 self.date_reviewed = _date_reviewed
460 # Record the reviewed revision id
461 self.reviewed_revision_id = revision_id
462- notify(BranchMergeProposalStatusChangeEvent(
463- self, reviewer, old_state, next_state))
464
465 def approveBranch(self, reviewer, revision_id, _date_reviewed=None):
466 """See `IBranchMergeProposal`."""
467
468=== modified file 'lib/lp/code/model/branchmergeproposaljob.py'
469--- lib/lp/code/model/branchmergeproposaljob.py 2015-08-03 09:52:29 +0000
470+++ lib/lp/code/model/branchmergeproposaljob.py 2015-10-06 15:19:46 +0000
471@@ -284,6 +284,7 @@
472 Or(Branch.next_mirror_time == None,
473 Branch.branch_type != BranchType.HOSTED)),
474 BranchMergeProposal.source_git_repository != None)))
475+ jobs = jobs.config(distinct=True)
476 return (klass(job) for job in jobs)
477
478 def getOopsVars(self):
479
480=== modified file 'lib/lp/code/model/tests/test_branchmergeproposal.py'
481--- lib/lp/code/model/tests/test_branchmergeproposal.py 2015-09-28 17:38:45 +0000
482+++ lib/lp/code/model/tests/test_branchmergeproposal.py 2015-10-06 15:19:46 +0000
483@@ -1,4 +1,4 @@
484-# Copyright 2009-2014 Canonical Ltd. This software is licensed under the
485+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
486 # GNU Affero General Public License version 3 (see the file LICENSE).
487
488 """Tests for BranchMergeProposals."""
489@@ -514,7 +514,7 @@
490 commenter = self.factory.makePerson()
491 login_person(commenter)
492 result, events = self.assertNotifies(
493- NewCodeReviewCommentEvent,
494+ NewCodeReviewCommentEvent, False,
495 merge_proposal.createComment,
496 owner=commenter,
497 subject='A review.')
498@@ -661,7 +661,7 @@
499 registrant = self.factory.makePerson()
500 result, events = self.assertNotifies(
501 [NewBranchMergeProposalEvent,
502- BranchMergeProposalNeedsReviewEvent],
503+ BranchMergeProposalNeedsReviewEvent], False,
504 source_branch.addLandingTarget, registrant, target_branch,
505 needs_review=True)
506 self.assertEqual(result, events[0].object)
507@@ -674,7 +674,7 @@
508 product=source_branch.product)
509 registrant = self.factory.makePerson()
510 result, events = self.assertNotifies(
511- [NewBranchMergeProposalEvent],
512+ [NewBranchMergeProposalEvent], False,
513 source_branch.addLandingTarget, registrant, target_branch)
514 self.assertEqual(result, events[0].object)
515
516@@ -685,7 +685,7 @@
517 set_state=BranchMergeProposalStatus.WORK_IN_PROGRESS)
518 with person_logged_in(bmp.registrant):
519 self.assertNotifies(
520- [BranchMergeProposalNeedsReviewEvent],
521+ [BranchMergeProposalNeedsReviewEvent], False,
522 bmp.setStatus, BranchMergeProposalStatus.NEEDS_REVIEW)
523
524 def test_needs_review_no_op(self):
525@@ -1285,7 +1285,7 @@
526 bmp = self.factory.makeBranchMergeProposal()
527 login_person(bmp.target_branch.owner)
528 self.assertNotifies(
529- ObjectModifiedEvent, notify_modified, bmp, bmp.markAsMerged,
530+ ObjectModifiedEvent, False, notify_modified, bmp, bmp.markAsMerged,
531 merge_reporter=bmp.target_branch.owner)
532 self.assertEqual(BranchMergeProposalStatus.MERGED, bmp.queue_status)
533 self.assertEqual(bmp.target_branch.owner, bmp.merge_reporter)
534@@ -1305,7 +1305,7 @@
535 login_person(merge_proposal.source_branch.owner)
536 reviewer = self.factory.makePerson()
537 result, events = self.assertNotifies(
538- ReviewerNominatedEvent,
539+ ReviewerNominatedEvent, False,
540 merge_proposal.nominateReviewer,
541 reviewer=reviewer,
542 registrant=merge_proposal.source_branch.owner)
543
544=== modified file 'lib/lp/code/model/tests/test_codereviewcomment.py'
545--- lib/lp/code/model/tests/test_codereviewcomment.py 2012-01-15 10:43:27 +0000
546+++ lib/lp/code/model/tests/test_codereviewcomment.py 2015-10-06 15:19:46 +0000
547@@ -1,4 +1,4 @@
548-# Copyright 2009 Canonical Ltd. This software is licensed under the
549+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
550 # GNU Affero General Public License version 3 (see the file LICENSE).
551
552 """Unit tests for CodeReviewComment"""
553@@ -107,7 +107,8 @@
554 """Creating a CodeReviewComment should trigger a notification."""
555 message = self.factory.makeMessage()
556 self.assertNotifies(
557- NewCodeReviewCommentEvent, self.bmp.createCommentFromMessage,
558+ NewCodeReviewCommentEvent, False,
559+ self.bmp.createCommentFromMessage,
560 message, None, None, original_email=None, _validate=False)
561
562
563
564=== modified file 'lib/lp/code/model/tests/test_codereviewkarma.py'
565--- lib/lp/code/model/tests/test_codereviewkarma.py 2011-12-30 07:38:46 +0000
566+++ lib/lp/code/model/tests/test_codereviewkarma.py 2015-10-06 15:19:46 +0000
567@@ -1,10 +1,11 @@
568-# Copyright 2009 Canonical Ltd. This software is licensed under the
569+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
570 # GNU Affero General Public License version 3 (see the file LICENSE).
571
572 """Tests for karma allocated for code reviews."""
573
574 __metaclass__ = type
575
576+from lp.code.interfaces.branchmergeproposal import notify_modified
577 from lp.registry.interfaces.karma import IKarmaAssignedEvent
578 from lp.registry.interfaces.person import IPerson
579 from lp.testing import (
580@@ -113,7 +114,9 @@
581 proposal = self.factory.makeBranchMergeProposal()
582 reviewer = proposal.target_branch.owner
583 self.karma_events = []
584- proposal.approveBranch(reviewer, "A rev id.")
585+ login_person(reviewer)
586+ notify_modified(
587+ proposal, proposal.approveBranch, reviewer, "A rev id.")
588 self.assertOneKarmaEvent(reviewer, 'branchmergeapproved')
589
590 def test_approvingOwnCodeReview(self):
591@@ -123,7 +126,9 @@
592 proposal = self.factory.makeBranchMergeProposal(
593 target_branch=target_branch, registrant=reviewer)
594 self.karma_events = []
595- proposal.approveBranch(reviewer, "A rev id.")
596+ login_person(reviewer)
597+ notify_modified(
598+ proposal, proposal.approveBranch, reviewer, "A rev id.")
599 self.assertOneKarmaEvent(reviewer, 'branchmergeapprovedown')
600
601 def test_rejectedCodeReview(self):
602@@ -132,7 +137,8 @@
603 proposal = self.factory.makeBranchMergeProposal()
604 reviewer = proposal.target_branch.owner
605 self.karma_events = []
606- proposal.rejectBranch(reviewer, "A rev id.")
607+ login_person(reviewer)
608+ notify_modified(proposal, proposal.rejectBranch, reviewer, "A rev id.")
609 self.assertOneKarmaEvent(reviewer, 'branchmergerejected')
610
611 def test_rejectedOwnCodeReview(self):
612@@ -144,5 +150,6 @@
613 proposal = self.factory.makeBranchMergeProposal(
614 target_branch=target_branch, registrant=reviewer)
615 self.karma_events = []
616- proposal.rejectBranch(reviewer, "A rev id.")
617+ login_person(reviewer)
618+ notify_modified(proposal, proposal.rejectBranch, reviewer, "A rev id.")
619 self.assertOneKarmaEvent(reviewer, 'branchmergerejectedown')
620
621=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
622--- lib/lp/code/model/tests/test_gitrepository.py 2015-09-02 16:54:24 +0000
623+++ lib/lp/code/model/tests/test_gitrepository.py 2015-10-06 15:19:46 +0000
624@@ -51,6 +51,7 @@
625 GitRepositoryExists,
626 GitTargetError,
627 )
628+from lp.code.event.git import GitRefsUpdatedEvent
629 from lp.code.interfaces.branchmergeproposal import (
630 BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES,
631 )
632@@ -1864,7 +1865,7 @@
633 hosting_client.detectMerges = FakeMethod(
634 result={source_1.commit_sha1: u"0" * 40})
635 self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
636- repository.createOrUpdateRefs({
637+ refs_info = {
638 u"refs/heads/target-1": {
639 u"sha1": u"0" * 40,
640 u"type": GitObjectType.COMMIT,
641@@ -1872,8 +1873,12 @@
642 u"refs/heads/target-2": {
643 u"sha1": u"1" * 40,
644 u"type": GitObjectType.COMMIT,
645- }
646- })
647+ },
648+ }
649+ expected_events = [
650+ ObjectModifiedEvent, ObjectModifiedEvent, GitRefsUpdatedEvent]
651+ _, events = self.assertNotifies(
652+ expected_events, True, repository.createOrUpdateRefs, refs_info)
653 expected_args = [
654 (repository.getInternalPath(), target_1.commit_sha1,
655 set([source_1.commit_sha1, source_2.commit_sha1])),
656@@ -1888,6 +1893,15 @@
657 BranchMergeProposalStatus.WORK_IN_PROGRESS, bmp2.queue_status)
658 self.assertEqual(BranchMergeProposalStatus.MERGED, bmp3.queue_status)
659 self.assertEqual(u"0" * 40, bmp3.merged_revision_id)
660+ # The two ObjectModifiedEvents indicate the queue_status changes.
661+ self.assertContentEqual(
662+ [bmp1, bmp3], [event.object for event in events[:2]])
663+ self.assertContentEqual(
664+ [(BranchMergeProposalStatus.WORK_IN_PROGRESS,
665+ BranchMergeProposalStatus.MERGED)],
666+ set((event.object_before_modification.queue_status,
667+ event.object.queue_status)
668+ for event in events[:2]))
669
670
671 class TestGitRepositorySet(TestCaseWithFactory):
672
673=== modified file 'lib/lp/code/subscribers/branchmergeproposal.py'
674--- lib/lp/code/subscribers/branchmergeproposal.py 2013-04-09 09:47:58 +0000
675+++ lib/lp/code/subscribers/branchmergeproposal.py 2015-10-06 15:19:46 +0000
676@@ -1,4 +1,4 @@
677-# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
678+# Copyright 2010-2015 Canonical Ltd. This software is licensed under the
679 # GNU Affero General Public License version 3 (see the file LICENSE).
680
681 """Event subscribers for branch merge proposals."""
682@@ -49,14 +49,19 @@
683 from_person = None
684 else:
685 from_person = IPerson(event.user)
686- # If the merge proposal was work in progress, then we don't want to send
687- # out an email as the needs review email will cover that.
688+ # If the merge proposal was work in progress and is now needs review,
689+ # then we don't want to send out an email as the needs review email will
690+ # cover that.
691 old_status = event.object_before_modification.queue_status
692- if old_status == BranchMergeProposalStatus.WORK_IN_PROGRESS:
693- # Unless the new status is merged. If this occurs we really should
694- # send out an email.
695- if merge_proposal.queue_status != BranchMergeProposalStatus.MERGED:
696- return
697+ new_status = merge_proposal.queue_status
698+
699+ in_progress_states = (
700+ BranchMergeProposalStatus.WORK_IN_PROGRESS,
701+ BranchMergeProposalStatus.NEEDS_REVIEW)
702+
703+ if (old_status == BranchMergeProposalStatus.WORK_IN_PROGRESS and
704+ new_status in in_progress_states):
705+ return
706 # Create a delta of the changes. If there are no changes to report, then
707 # we're done.
708 delta = BranchMergeProposalNoPreviewDiffDelta.construct(
709
710=== modified file 'lib/lp/code/subscribers/karma.py'
711--- lib/lp/code/subscribers/karma.py 2015-04-22 16:11:40 +0000
712+++ lib/lp/code/subscribers/karma.py 2015-10-06 15:19:46 +0000
713@@ -3,6 +3,8 @@
714
715 """Assign karma for code domain activity."""
716
717+from zope.principalregistry.principalregistry import UnauthenticatedPrincipal
718+
719 from lp.code.enums import BranchMergeProposalStatus
720 from lp.registry.interfaces.person import IPerson
721 from lp.services.database.sqlbase import block_implicit_flushes
722@@ -43,26 +45,33 @@
723
724
725 @block_implicit_flushes
726-def branch_merge_status_changed(proposal, event):
727- """Assign karma to the user who approved the merge."""
728+def branch_merge_modified(proposal, event):
729+ """Assign karma to the user who approved or rejected the merge."""
730+ if event.user is None or isinstance(event.user, UnauthenticatedPrincipal):
731+ # Some modification events have no associated user context. In
732+ # these cases there's no karma to assign.
733+ return
734+
735 if proposal.source_git_repository is not None:
736 target = proposal.source_git_repository.namespace
737 else:
738 target = proposal.source_branch.target
739 user = IPerson(event.user)
740+ old_status = event.object_before_modification.queue_status
741+ new_status = proposal.queue_status
742
743 in_progress_states = (
744 BranchMergeProposalStatus.WORK_IN_PROGRESS,
745 BranchMergeProposalStatus.NEEDS_REVIEW)
746
747- if ((event.to_state == BranchMergeProposalStatus.CODE_APPROVED) and
748- (event.from_state in (in_progress_states))):
749+ if ((new_status == BranchMergeProposalStatus.CODE_APPROVED) and
750+ (old_status in (in_progress_states))):
751 if user == proposal.registrant:
752 target.assignKarma(user, 'branchmergeapprovedown')
753 else:
754 target.assignKarma(user, 'branchmergeapproved')
755- elif ((event.to_state == BranchMergeProposalStatus.REJECTED) and
756- (event.from_state in (in_progress_states))):
757+ elif ((new_status == BranchMergeProposalStatus.REJECTED) and
758+ (old_status in (in_progress_states))):
759 if user == proposal.registrant:
760 target.assignKarma(user, 'branchmergerejectedown')
761 else:
762
763=== modified file 'lib/lp/codehosting/scanner/tests/test_mergedetection.py'
764--- lib/lp/codehosting/scanner/tests/test_mergedetection.py 2013-06-20 05:50:00 +0000
765+++ lib/lp/codehosting/scanner/tests/test_mergedetection.py 2015-10-06 15:19:46 +0000
766@@ -1,4 +1,4 @@
767-# Copyright 2009 Canonical Ltd. This software is licensed under the
768+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
769 # GNU Affero General Public License version 3 (see the file LICENSE).
770
771 """Tests for the scanner's merge detection."""
772@@ -8,6 +8,7 @@
773 import logging
774
775 from bzrlib.revision import NULL_REVISION
776+from lazr.lifecycle.event import ObjectModifiedEvent
777 import transaction
778 from zope.component import getUtility
779 from zope.event import notify
780@@ -271,7 +272,8 @@
781 self.assertNotEqual(
782 BranchLifecycleStatus.MERGED,
783 proposal.source_branch.lifecycle_status)
784- mergedetection.merge_detected(
785+ _, [event] = self.assertNotifies(
786+ [ObjectModifiedEvent], True, mergedetection.merge_detected,
787 logging.getLogger(),
788 proposal.source_branch, proposal.target_branch, proposal)
789 self.assertEqual(
790@@ -279,6 +281,12 @@
791 self.assertEqual(
792 BranchLifecycleStatus.MERGED,
793 proposal.source_branch.lifecycle_status)
794+ self.assertEqual(proposal, event.object)
795+ self.assertEqual(
796+ BranchMergeProposalStatus.WORK_IN_PROGRESS,
797+ event.object_before_modification.queue_status)
798+ self.assertEqual(
799+ BranchMergeProposalStatus.MERGED, event.object.queue_status)
800 job = IStore(proposal).find(
801 BranchMergeProposalJob,
802 BranchMergeProposalJob.branch_merge_proposal == proposal,
803
804=== modified file 'lib/lp/testing/__init__.py'
805--- lib/lp/testing/__init__.py 2015-09-30 00:38:30 +0000
806+++ lib/lp/testing/__init__.py 2015-10-06 15:19:46 +0000
807@@ -530,11 +530,14 @@
808 from lp.testing.matchers import Provides
809 self.assertThat(obj, Provides(interface))
810
811- def assertNotifies(self, event_types, callable_obj, *args, **kwargs):
812+ def assertNotifies(self, event_types, propagate, callable_obj,
813+ *args, **kwargs):
814 """Assert that a callable performs a given notification.
815
816 :param event_type: One or more event types that notification is
817 expected for.
818+ :param propagate: If True, propagate events to their normal
819+ subscribers.
820 :param callable_obj: The callable to call.
821 :param *args: The arguments to pass to the callable.
822 :param **kwargs: The keyword arguments to pass to the callable.
823@@ -543,7 +546,7 @@
824 """
825 if not isinstance(event_types, (list, tuple)):
826 event_types = [event_types]
827- with EventRecorder() as recorder:
828+ with EventRecorder(propagate=propagate) as recorder:
829 result = callable_obj(*args, **kwargs)
830 if len(recorder.events) == 0:
831 raise AssertionError('No notification was performed.')
832@@ -1241,21 +1244,27 @@
833 class EventRecorder:
834 """Intercept and record Zope events.
835
836- This prevents the events from propagating to their normal subscribers.
837- The recorded events can be accessed via the 'events' list.
838+ This prevents the events from propagating to their normal subscribers,
839+ unless `propagate=True` is passed to the constructor. The recorded
840+ events can be accessed via the 'events' list.
841 """
842
843- def __init__(self):
844+ def __init__(self, propagate=False):
845+ self.propagate = propagate
846 self.events = []
847 self.old_subscribers = None
848+ self.new_subscribers = None
849
850 def __enter__(self):
851 self.old_subscribers = zope.event.subscribers[:]
852- zope.event.subscribers[:] = [self.events.append]
853+ if not self.propagate:
854+ zope.event.subscribers[:] = []
855+ zope.event.subscribers.append(self.events.append)
856+ self.new_subscribers = zope.event.subscribers[:]
857 return self
858
859 def __exit__(self, exc_type, exc_value, traceback):
860- assert zope.event.subscribers == [self.events.append], (
861+ assert zope.event.subscribers == self.new_subscribers, (
862 'Subscriber list has been changed while running!')
863 zope.event.subscribers[:] = self.old_subscribers
864