← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-repository-delete-job into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-repository-delete-job into lp:launchpad with lp:~cjwatson/launchpad/branch-delete-job as a prerequisite.

Commit message:
Add a Git repository deletion job.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1793266 in Launchpad itself: "Unable to delete repository"
  https://bugs.launchpad.net/launchpad/+bug/1793266

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-repository-delete-job/+merge/364910

As in https://code.launchpad.net/~cjwatson/launchpad/branch-delete-job/+merge/364907.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-repository-delete-job into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2019-03-21 16:22:19 +0000
+++ database/schema/security.cfg	2019-03-21 16:22:19 +0000
@@ -2621,6 +2621,13 @@
 public.distributionsourcepackage        = SELECT, DELETE
 public.distroseries                     = SELECT
 public.emailaddress                     = SELECT
+public.gitactivity                      = SELECT, DELETE
+public.gitjob                           = SELECT, INSERT, DELETE
+public.gitref                           = SELECT, DELETE
+public.gitrepository                    = SELECT, UPDATE, DELETE
+public.gitrule                          = SELECT, DELETE
+public.gitrulegrant                     = SELECT, DELETE
+public.gitsubscription                  = SELECT, DELETE
 public.job                              = SELECT, INSERT, UPDATE, DELETE
 public.person                           = SELECT
 public.previewdiff                      = SELECT, DELETE

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2019-03-21 16:22:19 +0000
+++ lib/lp/code/configure.zcml	2019-03-21 16:22:19 +0000
@@ -1092,6 +1092,11 @@
       provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource">
     <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" />
   </securedutility>
+  <securedutility
+      component="lp.code.model.gitjob.GitRepositoryDeleteJob"
+      provides="lp.code.interfaces.gitjob.IGitRepositoryDeleteJobSource">
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryDeleteJobSource" />
+  </securedutility>
   <class class="lp.code.model.gitjob.GitRefScanJob">
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
@@ -1104,6 +1109,10 @@
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" />
   </class>
+  <class class="lp.code.model.gitjob.GitRepositoryDeleteJob">
+    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryDeleteJob" />
+  </class>
 
   <lp:help-folder folder="help" name="+help-code" />
 

=== modified file 'lib/lp/code/enums.py'
--- lib/lp/code/enums.py	2019-03-21 16:22:19 +0000
+++ lib/lp/code/enums.py	2019-03-21 16:22:19 +0000
@@ -26,6 +26,7 @@
     'GitGranteeType',
     'GitObjectType',
     'GitPermissionType',
+    'GitRepositoryDeletionStatus',
     'GitRepositoryType',
     'NON_CVS_RCS_TYPES',
     'RevisionControlSystems',
@@ -158,6 +159,17 @@
         """)
 
 
+class GitRepositoryDeletionStatus(DBEnumeratedType):
+    """Git Repository Deletion Status
+
+    The deletion status of a repository is used to track asynchronous
+    deletion.
+    """
+
+    ACTIVE = DBItem(0, "Active")
+    DELETING = DBItem(1, "Deleting")
+
+
 class GitObjectType(DBEnumeratedType):
     """Git Object Type
 

=== modified file 'lib/lp/code/interfaces/gitjob.py'
--- lib/lp/code/interfaces/gitjob.py	2015-09-01 17:10:46 +0000
+++ lib/lp/code/interfaces/gitjob.py	2019-03-21 16:22:19 +0000
@@ -9,6 +9,8 @@
     'IGitJob',
     'IGitRefScanJob',
     'IGitRefScanJobSource',
+    'IGitRepositoryDeleteJob',
+    'IGitRepositoryDeleteJobSource',
     'IGitRepositoryModifiedMailJob',
     'IGitRepositoryModifiedMailJobSource',
     'IReclaimGitRepositorySpaceJob',
@@ -20,7 +22,10 @@
     Attribute,
     Interface,
     )
-from zope.schema import Text
+from zope.schema import (
+    Int,
+    Text,
+    )
 
 from lp import _
 from lp.code.interfaces.gitrepository import IGitRepository
@@ -93,3 +98,19 @@
         :param repository_delta: An `IGitRepositoryDelta` describing the
             changes.
         """
+
+
+class IGitRepositoryDeleteJob(IRunnableJob):
+    """A Job that deletes a repository from the database."""
+
+    repository_id = Int(title=_("The id of the repository to delete."))
+
+
+class IGitRepositoryDeleteJobSource(IJobSource):
+
+    def create(repository, requester):
+        """Delete a repository from the database.
+
+        :param repository: The `IGitRepository` to delete.
+        :param requester: The `IPerson` who requested the deletion.
+        """

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2019-01-28 17:19:44 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2019-03-21 16:22:19 +0000
@@ -64,6 +64,7 @@
     BranchSubscriptionDiffSize,
     BranchSubscriptionNotificationLevel,
     CodeReviewNotificationLevel,
+    GitRepositoryDeletionStatus,
     GitRepositoryType,
     )
 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
@@ -139,6 +140,11 @@
             "The way this repository is hosted: directly on Launchpad, or "
             "imported from somewhere else.")))
 
+    deletion_status = exported(Choice(
+        title=_("Deletion status"), required=True, readonly=True,
+        vocabulary=GitRepositoryDeletionStatus,
+        description=_("The deletion status of this repository.")))
+
     registrant = exported(PublicPersonChoice(
         title=_("Registrant"), required=True, readonly=True,
         vocabulary="ValidPersonOrTeam",

=== modified file 'lib/lp/code/model/gitjob.py'
--- lib/lp/code/model/gitjob.py	2018-10-22 00:37:07 +0000
+++ lib/lp/code/model/gitjob.py	2019-03-21 16:22:19 +0000
@@ -8,6 +8,7 @@
     'GitJob',
     'GitJobType',
     'GitRefScanJob',
+    'GitRepositoryDeleteJob',
     'GitRepositoryModifiedMailJob',
     'ReclaimGitRepositorySpaceJob',
     ]
@@ -25,6 +26,7 @@
     SQL,
     Store,
     )
+import transaction
 from zope.component import getUtility
 from zope.interface import (
     implementer,
@@ -36,17 +38,22 @@
 from lp.code.enums import (
     GitActivityType,
     GitPermissionType,
+    GitRepositoryDeletionStatus,
     )
+from lp.code.errors import CannotDeleteGitRepository
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.interfaces.gitjob import (
     IGitJob,
     IGitRefScanJob,
     IGitRefScanJobSource,
+    IGitRepositoryDeleteJob,
+    IGitRepositoryDeleteJobSource,
     IGitRepositoryModifiedMailJob,
     IGitRepositoryModifiedMailJobSource,
     IReclaimGitRepositorySpaceJob,
     IReclaimGitRepositorySpaceJobSource,
     )
+from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitrule import describe_git_permissions
 from lp.code.mail.branch import BranchMailer
 from lp.registry.interfaces.person import IPersonSet
@@ -99,6 +106,12 @@
         modifications.
         """)
 
+    DELETE_REPOSITORY = DBItem(3, """
+        Delete repository
+
+        This job deletes a repository from the database.
+        """)
+
 
 @implementer(IGitJob)
 class GitJob(StormBase):
@@ -405,3 +418,63 @@
     def run(self):
         """See `IGitRepositoryModifiedMailJob`."""
         self.getMailer().sendAll()
+
+
+@implementer(IGitRepositoryDeleteJob)
+@provider(IGitRepositoryDeleteJobSource)
+class GitRepositoryDeleteJob(GitJobDerived):
+    """A Job that deletes a repository from the database."""
+
+    class_job_type = GitJobType.DELETE_REPOSITORY
+
+    user_error_types = (CannotDeleteGitRepository,)
+
+    config = config.IGitRepositoryDeleteJobSource
+
+    def getOperationDescription(self):
+        return "deleting a repository"
+
+    @classmethod
+    def create(cls, repository, requester):
+        """See `IGitRepositoryDeleteJobSource`."""
+        metadata = {
+            "repository_id": repository.id,
+            "repository_name": repository.unique_name,
+            }
+        # The GitJob has a repository of None, because we don't want to
+        # delete this job while trying to delete the repository.
+        git_job = GitJob(
+            None, cls.class_job_type, metadata, requester=requester)
+        job = cls(git_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @property
+    def repository_id(self):
+        return self.metadata["repository_id"]
+
+    def run(self):
+        """See `IGitRepositoryDeleteJob`."""
+        repository = getUtility(IGitLookup).get(self.repository_id)
+        if repository is None:
+            log.info(
+                "Skipping repository %s because it has already been deleted." %
+                self._cached_repository_name)
+        elif (repository.deletion_status !=
+              GitRepositoryDeletionStatus.DELETING):
+            log.warning(
+                "Skipping repository %s because its deletion status is not "
+                "DELETING." % self._cached_repository_name)
+        else:
+            try:
+                repository.destroySelf(break_references=True)
+            except CannotDeleteGitRepository:
+                # Set the deletion status back to ACTIVE so that it's
+                # possible to try again.  We don't attempt to undo the
+                # renaming at the moment.  Do this in its own transaction
+                # since the job runner will abort the transaction.
+                transaction.abort()
+                removeSecurityProxy(repository).deletion_status = (
+                    GitRepositoryDeletionStatus.ACTIVE)
+                transaction.commit()
+                raise

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2019-03-21 16:22:19 +0000
+++ lib/lp/code/model/gitrepository.py	2019-03-21 16:22:19 +0000
@@ -91,6 +91,7 @@
     GitGranteeType,
     GitObjectType,
     GitPermissionType,
+    GitRepositoryDeletionStatus,
     GitRepositoryType,
     )
 from lp.code.errors import (
@@ -282,6 +283,23 @@
     repository_type = EnumCol(
         dbName='repository_type', enum=GitRepositoryType, notNull=True)
 
+    _deletion_status = EnumCol(
+        dbName='deletion_status', enum=GitRepositoryDeletionStatus,
+        default=GitRepositoryDeletionStatus.ACTIVE)
+
+    @property
+    def deletion_status(self):
+        # XXX cjwatson 2019-03-19: Remove once this column has been
+        # backfilled.
+        if self._deletion_status is None:
+            return GitRepositoryDeletionStatus.ACTIVE
+        else:
+            return self._deletion_status
+
+    @deletion_status.setter
+    def deletion_status(self, value):
+        self._deletion_status = value
+
     registrant_id = Int(name='registrant', allow_none=False)
     registrant = Reference(registrant_id, 'Person.id')
 

=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
--- lib/lp/code/model/tests/test_gitjob.py	2018-10-21 17:38:05 +0000
+++ lib/lp/code/model/tests/test_gitjob.py	2019-03-21 16:22:19 +0000
@@ -13,6 +13,10 @@
     )
 import hashlib
 
+from fixtures import (
+    FakeLogger,
+    MockPatch,
+    )
 from lazr.lifecycle.snapshot import Snapshot
 import pytz
 from testtools.matchers import (
@@ -23,6 +27,7 @@
     MatchesStructure,
     )
 import transaction
+from zope.component import getUtility
 from zope.interface import providedBy
 from zope.security.proxy import removeSecurityProxy
 
@@ -30,6 +35,7 @@
 from lp.code.enums import (
     GitGranteeType,
     GitObjectType,
+    GitRepositoryDeletionStatus,
     )
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG,
@@ -37,8 +43,11 @@
 from lp.code.interfaces.gitjob import (
     IGitJob,
     IGitRefScanJob,
+    IGitRepositoryDeleteJob,
+    IGitRepositoryDeleteJobSource,
     IReclaimGitRepositorySpaceJob,
     )
+from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.model.gitjob import (
     describe_repository_delta,
     GitJob,
@@ -52,6 +61,7 @@
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.runner import JobRunner
+from lp.services.mail.sendmail import format_address_for_person
 from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import canonical_url
 from lp.services.webapp.snapshot import notify_modified
@@ -65,6 +75,7 @@
     DatabaseFunctionalLayer,
     ZopelessDatabaseLayer,
     )
+from lp.testing.mail_helpers import pop_notifications
 
 
 class TestGitJob(TestCaseWithFactory):
@@ -473,5 +484,98 @@
             snapshot, repository)
 
 
+class TestGitRepositoryDeleteJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_providesInterface(self):
+        repository = self.factory.makeGitRepository()
+        requester = repository.registrant
+        job = getUtility(IGitRepositoryDeleteJobSource).create(
+            repository, requester)
+        self.assertProvides(job, IGitRepositoryDeleteJob)
+
+    def test_run(self):
+        repository = self.factory.makeGitRepository()
+        repository_id = repository.id
+        requester = repository.registrant
+        job = getUtility(IGitRepositoryDeleteJobSource).create(
+            repository, requester)
+        removeSecurityProxy(repository).deletion_status = (
+            GitRepositoryDeletionStatus.DELETING)
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            job.run()
+        self.assertEqual('', logger.output)
+        self.assertIsNone(getUtility(IGitLookup).get(repository_id))
+
+    def test_already_deleted(self):
+        repository = self.factory.makeGitRepository()
+        repository_name = repository.unique_name
+        requester = repository.registrant
+        job = getUtility(IGitRepositoryDeleteJobSource).create(
+            repository, requester)
+        repository.destroySelf()
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            job.run()
+        self.assertEqual(
+            "Skipping repository %s because it has already been "
+            "deleted.\n" % repository_name,
+            logger.output)
+
+    def test_not_deleting(self):
+        # The job skips repositories that aren't DELETING.  This shouldn't
+        # be possible in practice, but is a guard against accidents.
+        repository = self.factory.makeGitRepository()
+        repository_id = repository.id
+        repository_name = repository.unique_name
+        self.assertNotEqual(
+            GitRepositoryDeletionStatus.DELETING, repository.deletion_status)
+        requester = repository.registrant
+        job = getUtility(IGitRepositoryDeleteJobSource).create(
+            repository, requester)
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            job.run()
+        self.assertEqual(
+            "Skipping repository %s because its deletion status is not "
+            "DELETING.\n" % repository_name,
+            logger.output)
+        self.assertEqual(repository, getUtility(IGitLookup).get(repository_id))
+
+    def test_error(self):
+        # If deleting the repository fails, an error message is sent to the
+        # requester and the deletion status is set back to ACTIVE.  This
+        # can't normally happen because the job always breaks references to
+        # the repository, so we patch in a failure to allow testing the
+        # error path.
+        repository = self.factory.makeGitRepository()
+        repository_id = repository.id
+        requester = repository.registrant
+        job = getUtility(IGitRepositoryDeleteJobSource).create(
+            repository, requester)
+        removeSecurityProxy(repository).deletion_status = (
+            GitRepositoryDeletionStatus.DELETING)
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            with MockPatch(
+                    "lp.code.model.gitrepository.GitRepository.canBeDeleted",
+                    return_value=False):
+                JobRunner([job]).runJobHandleError(job)
+        self.assertIn(
+            "failed with user error CannotDeleteGitRepository", logger.output)
+        self.assertEqual(repository, getUtility(IGitLookup).get(repository_id))
+        self.assertEqual(
+            GitRepositoryDeletionStatus.ACTIVE, repository.deletion_status)
+        self.assertEqual([], self.oopses)
+        [mail] = pop_notifications()
+        self.assertEqual(format_address_for_person(requester), mail["to"])
+        self.assertEqual(
+            "Launchpad error while deleting a repository", mail["subject"])
+        self.assertIn(
+            "Cannot delete Git repository", mail.get_payload(decode=True))
+
+
 # XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too,
 # but that isn't feasible until we have a proper turnip fixture.

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2019-02-11 12:31:06 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2019-03-21 16:22:19 +0000
@@ -638,7 +638,8 @@
             self.repository.canBeDeleted(),
             "A newly created repository should be able to be deleted.")
         repository_id = self.repository.id
-        self.repository.destroySelf()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf()
         self.assertIsNone(
             getUtility(IGitLookup).get(repository_id),
             "The repository has not been deleted.")
@@ -650,7 +651,8 @@
             CodeReviewNotificationLevel.NOEMAIL, self.user)
         self.assertTrue(self.repository.canBeDeleted())
         repository_id = self.repository.id
-        self.repository.destroySelf()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf()
         self.assertIsNone(
             getUtility(IGitLookup).get(repository_id),
             "The repository has not been deleted.")
@@ -665,7 +667,8 @@
             CodeReviewNotificationLevel.NOEMAIL, self.user)
         self.assertTrue(self.repository.canBeDeleted())
         repository_id = self.repository.id
-        self.repository.destroySelf()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf()
         self.assertIsNone(
             getUtility(IGitLookup).get(repository_id),
             "The repository has not been deleted.")
@@ -687,8 +690,9 @@
         self.assertFalse(
             self.repository.canBeDeleted(),
             "A repository with a landing target is not deletable.")
-        self.assertRaises(
-            CannotDeleteGitRepository, self.repository.destroySelf)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.assertRaises(
+                CannotDeleteGitRepository, self.repository.destroySelf)
 
     def test_landing_candidate_disables_deletion(self):
         # A repository with a landing candidate cannot be deleted.
@@ -698,8 +702,9 @@
         self.assertFalse(
             self.repository.canBeDeleted(),
              "A repository with a landing candidate is not deletable.")
-        self.assertRaises(
-            CannotDeleteGitRepository, self.repository.destroySelf)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.assertRaises(
+                CannotDeleteGitRepository, self.repository.destroySelf)
 
     def test_prerequisite_repository_disables_deletion(self):
         # A repository that is a prerequisite repository cannot be deleted.
@@ -711,14 +716,16 @@
         self.assertFalse(
             self.repository.canBeDeleted(),
             "A repository with a prerequisite target is not deletable.")
-        self.assertRaises(
-            CannotDeleteGitRepository, self.repository.destroySelf)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.assertRaises(
+                CannotDeleteGitRepository, self.repository.destroySelf)
 
     def test_related_GitJobs_deleted(self):
         # A repository with an associated job will delete those jobs.
         GitAPI(None, None).notify(self.repository.getInternalPath())
         store = Store.of(self.repository)
-        self.repository.destroySelf()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf()
         # Need to commit the transaction to fire off the constraint checks.
         transaction.commit()
         jobs = store.find(GitJob, GitJob.job_type == GitJobType.REF_SCAN)
@@ -729,7 +736,8 @@
         # to remove the repository from disk as well.
         repository_path = self.repository.getInternalPath()
         store = Store.of(self.repository)
-        self.repository.destroySelf()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf()
         jobs = store.find(
             GitJob,
             GitJob.job_type == GitJobType.RECLAIM_REPOSITORY_SPACE)
@@ -742,14 +750,16 @@
         # If repository is a base_git_repository in a recipe, it is deleted.
         recipe = self.factory.makeSourcePackageRecipe(
             branches=self.factory.makeGitRefs(owner=self.user))
-        recipe.base_git_repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            recipe.base_git_repository.destroySelf(break_references=True)
 
     def test_destroySelf_with_SourcePackageRecipe_as_non_base(self):
         # If repository is referred to by a recipe, it is deleted.
         [ref1] = self.factory.makeGitRefs(owner=self.user)
         [ref2] = self.factory.makeGitRefs(owner=self.user)
         self.factory.makeSourcePackageRecipe(branches=[ref1, ref2])
-        ref2.repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            ref2.repository.destroySelf(break_references=True)
 
     def test_destroySelf_with_inline_comments_draft(self):
         # Draft inline comments related to a deleted repository (source or
@@ -763,7 +773,8 @@
             previewdiff_id=preview_diff.id,
             person=self.user,
             comments={"1": "Should vanish."})
-        self.repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf(break_references=True)
 
     def test_destroySelf_with_inline_comments_published(self):
         # Published inline comments related to a deleted repository (source
@@ -779,12 +790,14 @@
             previewdiff_id=preview_diff.id,
             inline_comments={"1": "Must disappear."},
         )
-        self.repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf(break_references=True)
 
     def test_related_webhooks_deleted(self):
         webhook = self.factory.makeWebhook(target=self.repository)
         webhook.ping()
-        self.repository.destroySelf()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf()
         transaction.commit()
         self.assertRaises(LostObjectError, getattr, webhook, 'target')
 
@@ -796,7 +809,8 @@
         activities = store.find(
             GitActivity, GitActivity.repository_id == repository_id)
         self.assertNotEqual([], list(activities))
-        self.repository.destroySelf()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf()
         transaction.commit()
         self.assertRaises(LostObjectError, getattr, grant, 'rule')
         self.assertRaises(LostObjectError, getattr, rule, 'repository')
@@ -895,7 +909,8 @@
         merge_proposal1, merge_proposal2 = self.makeMergeProposals()
         merge_proposal1_id = merge_proposal1.id
         BranchMergeProposal.get(merge_proposal1_id)
-        self.repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            self.repository.destroySelf(break_references=True)
         self.assertRaises(
             SQLObjectNotFound, BranchMergeProposal.get, merge_proposal1_id)
 
@@ -905,8 +920,9 @@
         merge_proposal1, merge_proposal2 = self.makeMergeProposals()
         merge_proposal1_id = merge_proposal1.id
         BranchMergeProposal.get(merge_proposal1_id)
-        merge_proposal1.target_git_repository.destroySelf(
-            break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            merge_proposal1.target_git_repository.destroySelf(
+                break_references=True)
         self.assertRaises(SQLObjectNotFound,
             BranchMergeProposal.get, merge_proposal1_id)
 
@@ -914,8 +930,9 @@
         # Merge proposal prerequisite repositories can be deleted with
         # break_references.
         merge_proposal1, merge_proposal2 = self.makeMergeProposals()
-        merge_proposal1.prerequisite_git_repository.destroySelf(
-            break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            merge_proposal1.prerequisite_git_repository.destroySelf(
+                break_references=True)
         self.assertIsNone(merge_proposal1.prerequisite_git_repository)
 
     def test_delete_source_CodeReviewComment(self):
@@ -923,7 +940,8 @@
         comment = self.factory.makeCodeReviewComment(git=True)
         comment_id = comment.id
         repository = comment.branch_merge_proposal.source_git_repository
-        repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            repository.destroySelf(break_references=True)
         self.assertRaises(
             SQLObjectNotFound, CodeReviewComment.get, comment_id)
 
@@ -932,7 +950,8 @@
         comment = self.factory.makeCodeReviewComment(git=True)
         comment_id = comment.id
         repository = comment.branch_merge_proposal.target_git_repository
-        repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            repository.destroySelf(break_references=True)
         self.assertRaises(
             SQLObjectNotFound, CodeReviewComment.get, comment_id)
 
@@ -941,14 +960,18 @@
         merge_proposal = self.factory.makeBranchMergeProposalForGit()
         merge_proposal.nominateReviewer(
             self.factory.makePerson(), self.factory.makePerson())
-        merge_proposal.source_git_repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            merge_proposal.source_git_repository.destroySelf(
+                break_references=True)
 
     def test_targetBranchWithCodeReviewVoteReference(self):
         # break_references handles CodeReviewVoteReference target repository.
         merge_proposal = self.factory.makeBranchMergeProposalForGit()
         merge_proposal.nominateReviewer(
             self.factory.makePerson(), self.factory.makePerson())
-        merge_proposal.target_git_repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            merge_proposal.target_git_repository.destroySelf(
+                break_references=True)
 
     def test_code_import_requirements(self):
         # Code imports are not included explicitly in deletion requirements.
@@ -967,7 +990,8 @@
         code_import = self.factory.makeCodeImport(
             target_rcs_type=TargetRevisionControlSystems.GIT)
         code_import_id = code_import.id
-        code_import.git_repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            code_import.git_repository.destroySelf(break_references=True)
         self.assertRaises(
             NotFoundError, getUtility(ICodeImportSet).get, code_import_id)
 
@@ -988,7 +1012,8 @@
             repository=repository, paths=["refs/heads/1", "refs/heads/2"])
         snap1 = self.factory.makeSnap(git_ref=ref1)
         snap2 = self.factory.makeSnap(git_ref=ref2)
-        repository.destroySelf(break_references=True)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            repository.destroySelf(break_references=True)
         transaction.commit()
         self.assertIsNone(snap1.git_repository)
         self.assertIsNone(snap1.git_path)
@@ -1001,7 +1026,8 @@
         merge_proposal = removeSecurityProxy(self.makeMergeProposals()[0])
         with person_logged_in(
                 merge_proposal.prerequisite_git_repository.owner):
-            ClearPrerequisiteRepository(merge_proposal)()
+            with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+                ClearPrerequisiteRepository(merge_proposal)()
         self.assertIsNone(merge_proposal.prerequisite_git_repository)
 
     def test_DeletionOperation(self):
@@ -1012,8 +1038,9 @@
         # DeletionCallable must invoke the callable.
         merge_proposal = self.factory.makeBranchMergeProposalForGit()
         merge_proposal_id = merge_proposal.id
-        DeletionCallable(
-            merge_proposal, "blah", merge_proposal.deleteProposal)()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            DeletionCallable(
+                merge_proposal, "blah", merge_proposal.deleteProposal)()
         self.assertRaises(
             SQLObjectNotFound, BranchMergeProposal.get, merge_proposal_id)
 
@@ -1023,7 +1050,8 @@
         code_import = self.factory.makeCodeImport(
             target_rcs_type=TargetRevisionControlSystems.GIT)
         code_import_id = code_import.id
-        DeleteCodeImport(code_import)()
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            DeleteCodeImport(code_import)()
         self.assertRaises(
             NotFoundError, getUtility(ICodeImportSet).get, code_import_id)
 

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2019-03-21 16:22:19 +0000
+++ lib/lp/services/config/schema-lazr.conf	2019-03-21 16:22:19 +0000
@@ -1871,6 +1871,7 @@
     IBranchModifiedMailJobSource,
     ICommercialExpiredJobSource,
     IExpiringMembershipNotificationJobSource,
+    IGitRepositoryDeleteJobSource,
     IGitRepositoryModifiedMailJobSource,
     IMembershipNotificationJobSource,
     IPackageUploadNotificationJobSource,
@@ -1931,6 +1932,11 @@
 module: lp.code.interfaces.gitjob
 dbuser: branchscanner
 
+[IGitRepositoryDeleteJobSource]
+module: lp.code.interfaces.gitjob
+dbuser: branch-delete-job
+crontab_group: MAIN
+
 [IGitRepositoryModifiedMailJobSource]
 module: lp.code.interfaces.gitjob
 dbuser: send-branch-mail


Follow ups