← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/async-branch-notifications into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/async-branch-notifications into lp:launchpad.

Commit message:
Send branch/repository modification notifications from jobs rather than directly from the webapp.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/async-branch-notifications/+merge/269788

Send branch/repository modification notifications from jobs rather than directly from the webapp.

The motivation for this is to allow us to restrict BaseMailer to permissive security policies in the near future and thus get rid of a pile of security proxy problems; for instance this fixes an unreported bug whereby a branch subscription from a private team will cause modifications to that branch by a user who can't access that private team to OOPS.

Most of this is straightforward job handling.  The one really hairy bit is dealing with the upcoming mailer calling convention in pagetests.  Anticipating that this will be useful in a couple of other places shortly, I added a "permissive_security_policy" context manager which deals with the bits we need.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/async-branch-notifications into lp:launchpad.
=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2015-06-13 01:45:19 +0000
+++ lib/lp/code/configure.zcml	2015-09-01 17:16:20 +0000
@@ -585,6 +585,10 @@
     <allow interface="lp.code.interfaces.branchjob.IReclaimBranchSpaceJob"/>
     <allow interface="lp.code.interfaces.branchjob.IBranchJob"/>
   </class>
+  <class class="lp.code.model.branchjob.BranchModifiedMailJob">
+    <allow interface="lp.code.interfaces.branchjob.IBranchModifiedMailJob"/>
+    <allow interface="lp.code.interfaces.branchjob.IBranchJob"/>
+  </class>
   <securedutility
       component="lp.code.model.branchjob.RevisionMailJob"
       provides="lp.code.interfaces.branchjob.IRevisionMailJobSource">
@@ -605,6 +609,11 @@
       provides="lp.code.interfaces.branchjob.IReclaimBranchSpaceJobSource">
     <allow interface="lp.code.interfaces.branchjob.IReclaimBranchSpaceJobSource"/>
   </securedutility>
+  <securedutility
+      component="lp.code.model.branchjob.BranchModifiedMailJob"
+      provides="lp.code.interfaces.branchjob.IBranchModifiedMailJobSource">
+    <allow interface="lp.code.interfaces.branchjob.IBranchModifiedMailJobSource"/>
+  </securedutility>
 
   <!-- CodeImport -->
 
@@ -961,6 +970,11 @@
       provides="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJobSource">
     <allow interface="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJobSource" />
   </securedutility>
+  <securedutility
+      component="lp.code.model.gitjob.GitRepositoryModifiedMailJob"
+      provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource">
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" />
+  </securedutility>
   <class class="lp.code.model.gitjob.GitRefScanJob">
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
@@ -969,6 +983,10 @@
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJob" />
   </class>
+  <class class="lp.code.model.gitjob.GitRepositoryModifiedMailJob">
+    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" />
+  </class>
 
   <lp:help-folder folder="help" name="+help-code" />
 

=== modified file 'lib/lp/code/doc/branch-notifications.txt'
--- lib/lp/code/doc/branch-notifications.txt	2015-04-16 04:07:59 +0000
+++ lib/lp/code/doc/branch-notifications.txt	2015-09-01 17:16:20 +0000
@@ -421,6 +421,14 @@
     >>> from zope.event import notify
     >>> from lazr.lifecycle.event import ObjectModifiedEvent
     >>> from lazr.lifecycle.snapshot import Snapshot
+    >>> from lp.code.interfaces.branchjob import IBranchModifiedMailJobSource
+    >>> from lp.services.job.runner import JobRunner
+    >>> from lp.testing.dbuser import dbuser
+
+    >>> def run_modified_mail_jobs():
+    ...     job_source = getUtility(IBranchModifiedMailJobSource)
+    ...     with dbuser("branchscanner"):
+    ...         JobRunner.fromReady(job_source).runAll()
 
     >>> login('test@xxxxxxxxxxxxx')
     >>> before_modification = Snapshot(branch, providing=IBranch)
@@ -433,6 +441,7 @@
     >>> notify(ObjectModifiedEvent(branch,
     ...                               before_modification,
     ...                               ['whiteboard']))
+    >>> run_modified_mail_jobs()
 
     >>> notifications = pop_notifications()
     >>> len(notifications)
@@ -482,6 +491,7 @@
     >>> updated_fields = ['name','title','summary','url','whiteboard','lifecycle_status']
     >>> notify(ObjectModifiedEvent(
     ...     branch, before_modification, updated_fields))
+    >>> run_modified_mail_jobs()
 
     >>> notifications = pop_notifications()
     >>> len(notifications)
@@ -521,6 +531,7 @@
     >>> updated_fields = ['whiteboard']
     >>> notify(ObjectModifiedEvent(
     ...     branch, before_modification, updated_fields))
+    >>> run_modified_mail_jobs()
 
     >>> notifications = pop_notifications()
     >>> len(notifications)
@@ -563,6 +574,7 @@
     >>> updated_fields = ['whiteboard']
     >>> notify(ObjectModifiedEvent(
     ...     branch, before_modification, updated_fields))
+    >>> run_modified_mail_jobs()
 
     >>> notifications = pop_notifications()
     >>> len(notifications)

=== modified file 'lib/lp/code/interfaces/branchjob.py'
--- lib/lp/code/interfaces/branchjob.py	2013-07-04 07:18:21 +0000
+++ lib/lp/code/interfaces/branchjob.py	2015-09-01 17:16:20 +0000
@@ -9,6 +9,8 @@
 
 __all__ = [
     'IBranchJob',
+    'IBranchModifiedMailJob',
+    'IBranchModifiedMailJobSource',
     'IBranchScanJob',
     'IBranchScanJobSource',
     'IBranchUpgradeJob',
@@ -184,3 +186,18 @@
 
         :param branch_id: The id of the branch to remove from disk.
         """
+
+
+class IBranchModifiedMailJob(IRunnableJob):
+    """A Job to send email about branch modifications."""
+
+
+class IBranchModifiedMailJobSource(IJobSource):
+
+    def create(branch, user, branch_delta):
+        """Send email about branch modifications.
+
+        :param branch: The `IBranch` that was modified.
+        :param user: The `IPerson` who modified the branch.
+        :param branch_delta: An `IBranchDelta` describing the changes.
+        """

=== modified file 'lib/lp/code/interfaces/gitjob.py'
--- lib/lp/code/interfaces/gitjob.py	2015-05-19 11:29:24 +0000
+++ lib/lp/code/interfaces/gitjob.py	2015-09-01 17:16:20 +0000
@@ -9,6 +9,8 @@
     'IGitJob',
     'IGitRefScanJob',
     'IGitRefScanJobSource',
+    'IGitRepositoryModifiedMailJob',
+    'IGitRepositoryModifiedMailJobSource',
     'IReclaimGitRepositorySpaceJob',
     'IReclaimGitRepositorySpaceJobSource',
     ]
@@ -75,3 +77,19 @@
         :param repository_path: The storage path of the repository to remove
             from storage.
         """
+
+
+class IGitRepositoryModifiedMailJob(IRunnableJob):
+    """A Job to send email about repository modifications."""
+
+
+class IGitRepositoryModifiedMailJobSource(IJobSource):
+
+    def create(repository, user, repository_delta):
+        """Send email about repository modifications.
+
+        :param repository: The `IGitRepository` that was modified.
+        :param user: The `IPerson` who modified the repository.
+        :param repository_delta: An `IGitRepositoryDelta` describing the
+            changes.
+        """

=== modified file 'lib/lp/code/mail/branch.py'
--- lib/lp/code/mail/branch.py	2015-08-23 22:53:55 +0000
+++ lib/lp/code/mail/branch.py	2015-09-01 17:16:20 +0000
@@ -5,7 +5,7 @@
 
 __metaclass__ = type
 
-from zope.security.proxy import removeSecurityProxy
+from zope.component import getUtility
 
 from lp.code.adapters.branch import BranchDelta
 from lp.code.adapters.gitrepository import GitRepositoryDelta
@@ -15,35 +15,37 @@
     CodeReviewNotificationLevel,
     )
 from lp.code.interfaces.branch import IBranch
+from lp.code.interfaces.branchjob import IBranchModifiedMailJobSource
+from lp.code.interfaces.gitjob import IGitRepositoryModifiedMailJobSource
 from lp.code.interfaces.gitref import IGitRef
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
+from lp.services.config import config
 from lp.services.mail import basemailer
 from lp.services.mail.basemailer import BaseMailer
-from lp.services.mail.sendmail import format_address
+from lp.services.mail.sendmail import format_address_for_person
 from lp.services.webapp import canonical_url
 
 
 def send_branch_modified_notifications(branch, event):
-    """Notify the related people that a branch has been modifed."""
+    """Notify the related people that a branch has been modified."""
     user = IPerson(event.user)
     branch_delta = BranchDelta.construct(
         event.object_before_modification, branch, user)
     if branch_delta is None:
         return
-    mailer = BranchMailer.forBranchModified(branch, user, branch_delta)
-    mailer.sendAll()
+    getUtility(IBranchModifiedMailJobSource).create(branch, user, branch_delta)
 
 
 def send_git_repository_modified_notifications(repository, event):
-    """Notify the related people that a Git repository has been modifed."""
+    """Notify the related people that a Git repository has been modified."""
     user = IPerson(event.user)
     repository_delta = GitRepositoryDelta.construct(
         event.object_before_modification, repository, user)
     if repository_delta is None:
         return
-    mailer = BranchMailer.forBranchModified(repository, user, repository_delta)
-    mailer.sendAll()
+    getUtility(IGitRepositoryModifiedMailJobSource).create(
+        repository, user, repository_delta)
 
 
 class RecipientReason(basemailer.RecipientReason):
@@ -164,9 +166,10 @@
                  delta=None, contents=None, diff=None, message_id=None,
                  revno=None, revision_id=None, notification_type=None,
                  **kwargs):
-        BaseMailer.__init__(self, subject, template_name, recipients,
-                            from_address, delta, message_id,
-                            notification_type)
+        super(BranchMailer, self).__init__(
+            subject, template_name, recipients, from_address,
+            message_id=message_id, notification_type=notification_type)
+        self.delta_text = delta
         self.contents = contents
         self.diff = diff
         if diff is None:
@@ -193,7 +196,7 @@
         actual_recipients = {}
         # If the person editing the branch isn't in the team of the owner
         # then notify the branch owner of the changes as well.
-        if not user.inTeam(branch.owner):
+        if user is not None and not user.inTeam(branch.owner):
             # Existing rationales are kept.
             recipients.add(branch.owner, None, None)
         for recipient in recipients:
@@ -208,8 +211,10 @@
                 actual_recipients[recipient] = \
                     RecipientReason.forBranchSubscriber(
                     subscription, branch, recipient, rationale)
-        from_address = format_address(
-            user.displayname, removeSecurityProxy(user).preferredemail.email)
+        if user is not None:
+            from_address = format_address_for_person(user)
+        else:
+            from_address = config.canonical.noreply_from_address
         return cls(
             '[Branch %(unique_name)s]', 'branch-modified.txt',
             actual_recipients, from_address, delta=delta,
@@ -283,7 +288,8 @@
         params['diff'] = self.contents or ''
         if not self._includeDiff(email):
             params['diff'] += self._explainNotPresentDiff(email)
-        params.setdefault('delta', '')
+        params['delta'] = (
+            self.delta_text if self.delta_text is not None else '')
         params.update(self.extra_template_params)
         return params
 
@@ -331,8 +337,3 @@
         ctrl.addAttachment(
             self.diff, content_type='text/x-diff', inline=True,
                 filename='revision-diff.txt', charset='utf-8')
-
-    @staticmethod
-    def _format_user_address(user):
-        naked_email = removeSecurityProxy(user).preferredemail.email
-        return format_address(user.displayname, naked_email)

=== modified file 'lib/lp/code/mail/branchmergeproposal.py'
--- lib/lp/code/mail/branchmergeproposal.py	2015-08-23 22:53:55 +0000
+++ lib/lp/code/mail/branchmergeproposal.py	2015-09-01 17:16:20 +0000
@@ -10,7 +10,10 @@
 from lp.code.mail.branch import BranchMailer
 from lp.services.config import config
 from lp.services.mail.basemailer import BaseMailer
-from lp.services.mail.sendmail import get_msgid
+from lp.services.mail.sendmail import (
+    format_address_for_person,
+    get_msgid,
+    )
 from lp.services.webapp import canonical_url
 
 
@@ -23,13 +26,13 @@
                  direct_email=False):
         BranchMailer.__init__(
             self, subject, template_name, recipients, from_address,
-            message_id=message_id, notification_type='code-review')
+            delta=delta, message_id=message_id,
+            notification_type='code-review')
         self.merge_proposal = merge_proposal
         if requested_reviews is None:
             requested_reviews = []
         self.requested_reviews = requested_reviews
         self.preview_diff = preview_diff
-        self.delta_text = delta
         self.template_params = self._generateTemplateParams()
         self.direct_email = direct_email
 
@@ -51,7 +54,7 @@
 
         assert from_user.preferredemail is not None, (
             'The sender must have an email address.')
-        from_address = cls._format_user_address(from_user)
+        from_address = format_address_for_person(from_user)
 
         return cls(
             '%(proposal_title)s',
@@ -73,7 +76,7 @@
         if from_user is not None:
             assert from_user.preferredemail is not None, (
                 'The sender must have an email address.')
-            from_address = cls._format_user_address(from_user)
+            from_address = format_address_for_person(from_user)
         else:
             from_address = config.canonical.noreply_from_address
         return cls(
@@ -85,7 +88,7 @@
     @classmethod
     def forReviewRequest(cls, reason, merge_proposal, from_user):
         """Return a mailer for a request to review a BranchMergeProposal."""
-        from_address = cls._format_user_address(from_user)
+        from_address = format_address_for_person(from_user)
         recipients = {reason.subscriber: reason}
         return cls(
             '%(proposal_title)s',
@@ -114,7 +117,7 @@
                 continue
             if vote.reviewer.hide_email_addresses:
                 continue
-            to_addrs.append(self._format_user_address(vote.reviewer))
+            to_addrs.append(format_address_for_person(vote.reviewer))
         return to_addrs
 
     def _getHeaders(self, email, recipient):

=== modified file 'lib/lp/code/mail/tests/test_branchmergeproposal.py'
--- lib/lp/code/mail/tests/test_branchmergeproposal.py	2014-11-14 22:31:05 +0000
+++ lib/lp/code/mail/tests/test_branchmergeproposal.py	2015-09-01 17:16:20 +0000
@@ -32,6 +32,7 @@
 from lp.code.model.diff import PreviewDiff
 from lp.code.subscribers.branchmergeproposal import merge_proposal_modified
 from lp.services.database.interfaces import IStore
+from lp.services.mail.sendmail import format_address_for_person
 from lp.services.webapp import canonical_url
 from lp.testing import (
     person_logged_in,
@@ -135,7 +136,7 @@
              'Message-Id': '<foobar-example-com>'},
             ctrl.headers)
         self.assertEqual('Baz Qux <baz.qux@xxxxxxxxxxx>', ctrl.from_addr)
-        reviewer_id = mailer._format_user_address(reviewer)
+        reviewer_id = format_address_for_person(reviewer)
         self.assertEqual(set([reviewer_id, bmp.address]), set(ctrl.to_addrs))
         mailer.sendAll()
 
@@ -284,7 +285,7 @@
         ctrl = mailer.generateEmail(bmp.registrant.preferredemail.email,
                                     bmp.registrant)
         reviewer = request.recipient
-        reviewer_id = mailer._format_user_address(reviewer)
+        reviewer_id = format_address_for_person(reviewer)
         self.assertEqual(set([reviewer_id, bmp.address]), set(ctrl.to_addrs))
 
     def test_to_addrs_excludes_team_reviewers(self):
@@ -297,7 +298,7 @@
         ctrl = mailer.generateEmail(subscriber.preferredemail.email,
                                     subscriber)
         reviewer = bmp.target_branch.owner
-        reviewer_id = mailer._format_user_address(reviewer)
+        reviewer_id = format_address_for_person(reviewer)
         self.assertEqual(set([reviewer_id, bmp.address]), set(ctrl.to_addrs))
 
     def test_to_addrs_excludes_people_with_hidden_addresses(self):
@@ -557,7 +558,7 @@
             request, request.merge_proposal, requester)
         ctrl = mailer.generateEmail(request.recipient.preferredemail.email,
                                     request.recipient)
-        recipient_addr = mailer._format_user_address(request.recipient)
+        recipient_addr = format_address_for_person(request.recipient)
         self.assertEqual([recipient_addr], ctrl.to_addrs)
 
     def test_forReviewRequestMessageId(self):

=== modified file 'lib/lp/code/model/branchjob.py'
--- lib/lp/code/model/branchjob.py	2015-07-09 20:06:17 +0000
+++ lib/lp/code/model/branchjob.py	2015-09-01 17:16:20 +0000
@@ -3,6 +3,7 @@
 
 __all__ = [
     'BranchJob',
+    'BranchModifiedMailJob',
     'BranchScanJob',
     'BranchJobDerived',
     'BranchJobType',
@@ -67,6 +68,8 @@
     )
 from lp.code.interfaces.branchjob import (
     IBranchJob,
+    IBranchModifiedMailJob,
+    IBranchModifiedMailJobSource,
     IBranchScanJob,
     IBranchScanJobSource,
     IBranchUpgradeJob,
@@ -93,6 +96,7 @@
     get_rw_server,
     )
 from lp.codehosting.vfs.branchfs import get_real_branch_path
+from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.productseries import IProductSeriesSet
 from lp.scripts.helpers import TransactionFreeOperation
 from lp.services.config import config
@@ -117,6 +121,7 @@
     BaseRunnableJobSource,
     )
 from lp.services.mail.sendmail import format_address_for_person
+from lp.services.utils import text_delta
 from lp.services.webapp import canonical_url
 from lp.translations.interfaces.translationimportqueue import (
     ITranslationImportQueue,
@@ -184,6 +189,12 @@
         This job scans a branch for new revisions.
         """)
 
+    BRANCH_MODIFIED_MAIL = DBItem(8, """
+        Branch modified mail
+
+        This job runs against a branch to send emails about modifications.
+        """)
+
 
 @implementer(IBranchJob)
 class BranchJob(SQLBase):
@@ -990,3 +1001,44 @@
         branch_path = get_real_branch_path(self.branch_id)
         if os.path.exists(branch_path):
             shutil.rmtree(branch_path)
+
+
+@implementer(IBranchModifiedMailJob)
+@provider(IBranchModifiedMailJobSource)
+class BranchModifiedMailJob(BranchJobDerived):
+    """A Job that sends email about branch modifications."""
+
+    class_job_type = BranchJobType.BRANCH_MODIFIED_MAIL
+
+    config = config.IBranchModifiedMailJobSource
+
+    @classmethod
+    def create(cls, branch, user, branch_delta):
+        """See `IBranchModifiedMailJobSource`."""
+        metadata = {
+            'user': user.id,
+            'branch_delta': text_delta(
+                branch_delta, branch_delta.delta_values,
+                branch_delta.new_values, branch_delta.interface),
+            }
+        branch_job = BranchJob(branch, cls.class_job_type, metadata)
+        job = cls(branch_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @property
+    def user(self):
+        return getUtility(IPersonSet).get(self.metadata['user'])
+
+    @property
+    def branch_delta(self):
+        return self.metadata['branch_delta']
+
+    def getMailer(self):
+        """Return a `BranchMailer` for this job."""
+        return BranchMailer.forBranchModified(
+            self.branch, self.user, self.branch_delta)
+
+    def run(self):
+        """See `IBranchModifiedMailJob`."""
+        self.getMailer().sendAll()

=== modified file 'lib/lp/code/model/gitjob.py'
--- lib/lp/code/model/gitjob.py	2015-08-10 12:51:52 +0000
+++ lib/lp/code/model/gitjob.py	2015-09-01 17:16:20 +0000
@@ -7,6 +7,7 @@
     'GitJob',
     'GitJobType',
     'GitRefScanJob',
+    'GitRepositoryModifiedMailJob',
     'ReclaimGitRepositorySpaceJob',
     ]
 
@@ -36,9 +37,13 @@
     IGitJob,
     IGitRefScanJob,
     IGitRefScanJobSource,
+    IGitRepositoryModifiedMailJob,
+    IGitRepositoryModifiedMailJobSource,
     IReclaimGitRepositorySpaceJob,
     IReclaimGitRepositorySpaceJobSource,
     )
+from lp.code.mail.branch import BranchMailer
+from lp.registry.interfaces.person import IPersonSet
 from lp.services.config import config
 from lp.services.database.enumcol import EnumCol
 from lp.services.database.interfaces import (
@@ -59,6 +64,7 @@
 from lp.services.job.runner import BaseRunnableJob
 from lp.services.mail.sendmail import format_address_for_person
 from lp.services.scripts import log
+from lp.services.utils import text_delta
 from lp.services.webhooks.interfaces import IWebhookSet
 
 
@@ -78,6 +84,13 @@
         database from storage.
         """)
 
+    REPOSITORY_MODIFIED_MAIL = DBItem(2, """
+        Repository modified mail
+
+        This job runs against a repository to send emails about
+        modifications.
+        """)
+
 
 @implementer(IGitJob)
 class GitJob(StormBase):
@@ -281,3 +294,44 @@
 
     def run(self):
         getUtility(IGitHostingClient).delete(self.repository_path, logger=log)
+
+
+@implementer(IGitRepositoryModifiedMailJob)
+@provider(IGitRepositoryModifiedMailJobSource)
+class GitRepositoryModifiedMailJob(GitJobDerived):
+    """A Job that sends email about repository modifications."""
+
+    class_job_type = GitJobType.REPOSITORY_MODIFIED_MAIL
+
+    config = config.IGitRepositoryModifiedMailJobSource
+
+    @classmethod
+    def create(cls, repository, user, repository_delta):
+        """See `IGitRepositoryModifiedMailJobSource`."""
+        metadata = {
+            "user": user.id,
+            "repository_delta": text_delta(
+                repository_delta, repository_delta.delta_values,
+                repository_delta.new_values, repository_delta.interface),
+            }
+        git_job = GitJob(repository, cls.class_job_type, metadata)
+        job = cls(git_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @property
+    def user(self):
+        return getUtility(IPersonSet).get(self.metadata["user"])
+
+    @property
+    def repository_delta(self):
+        return self.metadata["repository_delta"]
+
+    def getMailer(self):
+        """Return a `BranchMailer` for this job."""
+        return BranchMailer.forBranchModified(
+            self.repository, self.user, self.repository_delta)
+
+    def run(self):
+        """See `IGitRepositoryModifiedMailJob`."""
+        self.getMailer().sendAll()

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2015-08-06 12:03:36 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2015-09-01 17:16:20 +0000
@@ -56,7 +56,10 @@
     )
 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
 from lp.code.interfaces.githosting import IGitHostingClient
-from lp.code.interfaces.gitjob import IGitRefScanJobSource
+from lp.code.interfaces.gitjob import (
+    IGitRefScanJobSource,
+    IGitRepositoryModifiedMailJobSource,
+    )
 from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitnamespace import (
     IGitNamespacePolicy,
@@ -746,7 +749,9 @@
             notify(ObjectModifiedEvent(
                 repository, repository_before_modification, ["name"],
                 user=repository.owner))
-        transaction.commit()
+        with dbuser("branchscanner"):
+            JobRunner.fromReady(
+                getUtility(IGitRepositoryModifiedMailJobSource)).runAll()
         self.assertEqual(1, len(stub.test_emails))
         message = email.message_from_string(stub.test_emails[0][2])
         body = message.get_payload(decode=True)

=== modified file 'lib/lp/code/stories/branches/xx-branch-edit.txt'
--- lib/lp/code/stories/branches/xx-branch-edit.txt	2013-09-27 04:13:23 +0000
+++ lib/lp/code/stories/branches/xx-branch-edit.txt	2015-09-01 17:16:20 +0000
@@ -63,6 +63,16 @@
 Emails go out to the all the subscribers.  Now there are no subscribers
 so there whould be no emails.
 
+    >>> from zope.component import getUtility
+    >>> from lp.code.interfaces.branchjob import IBranchModifiedMailJobSource
+    >>> from lp.services.job.runner import JobRunner
+
+    >>> def run_modified_mail_jobs():
+    ...     with permissive_security_policy("branchscanner"):
+    ...         job_source = getUtility(IBranchModifiedMailJobSource)
+    ...         JobRunner.fromReady(job_source).runAll()
+
+    >>> run_modified_mail_jobs()
     >>> len(stub.test_emails)
     0
 
@@ -214,7 +224,6 @@
 The whiteboard is only visible and editable on import branches, and is
 editable by any user.
 
-    >>> from zope.component import getUtility
     >>> from lp.registry.interfaces.person import IPersonSet
     >>> from lp.code.enums import (
     ...     BranchSubscriptionNotificationLevel, BranchSubscriptionDiffSize,
@@ -261,6 +270,7 @@
 The subscribers of the branch are notified that someone else has
 modified the details of the branch.
 
+    >>> run_modified_mail_jobs()
     >>> len(stub.test_emails)
     1
 

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2015-08-28 08:38:54 +0000
+++ lib/lp/services/config/schema-lazr.conf	2015-09-01 17:16:20 +0000
@@ -1741,7 +1741,9 @@
 # dbuser, the crontab_group, and the module that the job source class
 # can be loaded from.
 job_sources:
+    IBranchModifiedMailJobSource,
     ICommercialExpiredJobSource,
+    IGitRepositoryModifiedMailJobSource,
     IMembershipNotificationJobSource,
     IPersonDeactivateJobSource,
     IPersonMergeJobSource,
@@ -1758,6 +1760,11 @@
 dbuser: merge-proposal-jobs
 runner_class: TwistedJobRunner
 
+[IBranchModifiedMailJobSource]
+module: lp.code.interfaces.branchjob
+dbuser: branchscanner
+crontab_group: MAIN
+
 [IBranchScanJobSource]
 module: lp.code.interfaces.branchjob
 dbuser: branchscanner
@@ -1780,6 +1787,11 @@
 module: lp.code.interfaces.gitjob
 dbuser: branchscanner
 
+[IGitRepositoryModifiedMailJobSource]
+module: lp.code.interfaces.gitjob
+dbuser: branchscanner
+crontab_group: MAIN
+
 [IInitializeDistroSeriesJobSource]
 module: lp.soyuz.interfaces.distributionjob
 dbuser: initializedistroseries

=== modified file 'lib/lp/testing/pages.py'
--- lib/lp/testing/pages.py	2014-11-27 07:48:25 +0000
+++ lib/lp/testing/pages.py	2015-09-01 17:16:20 +0000
@@ -5,6 +5,7 @@
 
 __metaclass__ = type
 
+from contextlib import contextmanager
 from datetime import datetime
 import doctest
 from itertools import chain
@@ -39,6 +40,7 @@
     SimpleCookie,
     )
 from zope.component import getUtility
+from zope.security.management import setSecurityPolicy
 from zope.security.proxy import removeSecurityProxy
 from zope.session.interfaces import ISession
 from zope.testbrowser.testing import Browser
@@ -52,6 +54,7 @@
     OAUTH_REALM,
     )
 from lp.services.webapp import canonical_url
+from lp.services.webapp.authorization import LaunchpadPermissiveSecurityPolicy
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.services.webapp.url import urlsplit
@@ -61,7 +64,9 @@
     login,
     login_person,
     logout,
+    person_logged_in,
     )
+from lp.testing.dbuser import dbuser
 from lp.testing.factory import LaunchpadObjectFactory
 from lp.testing.layers import PageTestLayer
 from lp.testing.systemdocs import (
@@ -754,6 +759,26 @@
     return setupBrowser(auth='Basic re@xxxxxx:test')
 
 
+@contextmanager
+def permissive_security_policy(dbuser_name=None):
+    """Context manager to run code with a permissive security policy.
+
+    This is just enough to run code such as `BaseMailer` that normally
+    expects to be called only from environments that use a permissive
+    security policy, such as jobs or scripts.
+    """
+    try:
+        old_policy = setSecurityPolicy(LaunchpadPermissiveSecurityPolicy)
+        if dbuser_name is not None:
+            dbuser_context = dbuser(dbuser_name)
+        else:
+            dbuser_context = contextmanager([None].__iter__)
+        with person_logged_in(ANONYMOUS), dbuser_context:
+            yield
+    finally:
+        setSecurityPolicy(old_policy)
+
+
 def setUpGlobs(test):
     test.globs['transaction'] = transaction
     test.globs['http'] = UnstickyCookieHTTPCaller()
@@ -794,6 +819,7 @@
     test.globs['login_person'] = login_person
     test.globs['logout'] = logout
     test.globs['parse_relationship_section'] = parse_relationship_section
+    test.globs['permissive_security_policy'] = permissive_security_policy
     test.globs['pretty'] = pprint.PrettyPrinter(width=1).pformat
     test.globs['print_action_links'] = print_action_links
     test.globs['print_errors'] = print_errors


Follow ups