← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Review: Approve code



Diff comments:

> === modified file 'database/schema/security.cfg'
> --- database/schema/security.cfg	2015-05-19 00:07:44 +0000
> +++ database/schema/security.cfg	2015-05-19 11:33:34 +0000
> @@ -2039,6 +2039,7 @@
>  [reclaim-branch-space]
>  groups=script
>  public.branchjob                        = SELECT
> +public.gitjob                           = SELECT
>  public.job                              = SELECT, UPDATE
>  type=user
>  
> 
> === modified file 'lib/lp/code/configure.zcml'
> --- lib/lp/code/configure.zcml	2015-04-22 14:52:10 +0000
> +++ lib/lp/code/configure.zcml	2015-05-19 11:33:34 +0000
> @@ -204,7 +204,9 @@
>          permission="launchpad.Edit"
>          interface="lp.code.interfaces.branchmergeproposal.IBranchMergeProposalEdit"
>          set_attributes="description whiteboard merged_revno commit_message
> -                        root_message_id prerequisite_branch"/>
> +                        root_message_id prerequisite_branch
> +                        prerequisite_git_repository prerequisite_git_path
> +                        prerequisite_git_commit_sha1"/>
>      <require
>          permission="launchpad.AnyAllowedPerson"
>          interface="lp.code.interfaces.branchmergeproposal.IBranchMergeProposalAnyAllowedPerson"/>
> @@ -941,10 +943,19 @@
>        provides="lp.code.interfaces.gitjob.IGitRefScanJobSource">
>      <allow interface="lp.code.interfaces.gitjob.IGitRefScanJobSource" />
>    </securedutility>
> +  <securedutility
> +      component="lp.code.model.gitjob.ReclaimGitRepositorySpaceJob"
> +      provides="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJobSource">
> +    <allow interface="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJobSource" />
> +  </securedutility>
>    <class class="lp.code.model.gitjob.GitRefScanJob">
>      <allow interface="lp.code.interfaces.gitjob.IGitJob" />
>      <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
>    </class>
> +  <class class="lp.code.model.gitjob.ReclaimGitRepositorySpaceJob">
> +    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
> +    <allow interface="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJob" />
> +  </class>
>  
>    <lp:help-folder folder="help" name="+help-code" />
>  
> 
> === modified file 'lib/lp/code/errors.py'
> --- lib/lp/code/errors.py	2015-05-14 13:57:51 +0000
> +++ lib/lp/code/errors.py	2015-05-19 11:33:34 +0000
> @@ -20,6 +20,7 @@
>      'BuildNotAllowedForDistro',
>      'BranchMergeProposalExists',
>      'CannotDeleteBranch',
> +    'CannotDeleteGitRepository',
>      'CannotHaveLinkedBranch',
>      'CannotUpgradeBranch',
>      'CannotUpgradeNonHosted',
> @@ -34,6 +35,7 @@
>      'GitRepositoryCreationForbidden',
>      'GitRepositoryCreatorNotMemberOfOwnerTeam',
>      'GitRepositoryCreatorNotOwner',
> +    'GitRepositoryDeletionFault',
>      'GitRepositoryExists',
>      'GitRepositoryScanFault',
>      'GitTargetError',
> @@ -347,6 +349,11 @@
>          GitRepositoryCreationException.__init__(self, message)
>  
>  
> +@error_status(httplib.BAD_REQUEST)
> +class CannotDeleteGitRepository(Exception):
> +    """The Git repository cannot be deleted at this time."""
> +
> +
>  class GitRepositoryCreationForbidden(GitRepositoryCreationException):
>      """A visibility policy forbids Git repository creation.
>  
> @@ -381,6 +388,10 @@
>      """Raised when there is a fault scanning a repository."""
>  
>  
> +class GitRepositoryDeletionFault(Exception):
> +    """Raised when there is a fault deleting a repository."""
> +
> +
>  class GitTargetError(Exception):
>      """Raised when there is an error determining a Git repository target."""
>  
> 
> === modified file 'lib/lp/code/githosting.py'
> --- lib/lp/code/githosting.py	2015-04-30 17:10:28 +0000
> +++ lib/lp/code/githosting.py	2015-05-19 11:33:34 +0000
> @@ -15,6 +15,7 @@
>  
>  from lp.code.errors import (
>      GitRepositoryCreationFault,
> +    GitRepositoryDeletionFault,
>      GitRepositoryScanFault,
>      )
>  
> @@ -127,9 +128,23 @@
>          if response.status_code != 200:
>              raise GitRepositoryScanFault(
>                  "Failed to get merge diff from Git repository: %s" %
> -                unicode(e))
> +                response.text)
>          try:
>              return response.json()
>          except ValueError as e:
>              raise GitRepositoryScanFault(
>                  "Failed to decode merge-diff response: %s" % unicode(e))
> +
> +    def delete(self, path, logger=None):
> +        """Delete a repository."""
> +        try:
> +            if logger is not None:
> +                logger.info("Deleting repository %s" % path)
> +            response = self._makeSession().delete(
> +                urljoin(self.endpoint, "/repo/%s" % path))
> +        except Exception as e:
> +            raise GitRepositoryDeletionFault(
> +                "Failed to delete Git repository: %s" % unicode(e))
> +        if response.status_code != 200:
> +            raise GitRepositoryDeletionFault(
> +                "Failed to delete Git repository: %s" % response.text)
> 
> === modified file 'lib/lp/code/interfaces/branch.py'
> --- lib/lp/code/interfaces/branch.py	2015-04-30 21:49:59 +0000
> +++ lib/lp/code/interfaces/branch.py	2015-05-19 11:33:34 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Branch interfaces."""
> @@ -1167,15 +1167,9 @@
>              branch.
>          """
>  
> +    @call_with(break_references=True)
>      @export_destructor_operation()
>      @operation_for_version('beta')
> -    def destroySelfBreakReferences():
> -        """Delete the specified branch.
> -
> -        BranchRevisions associated with this branch will also be deleted as
> -        well as any items with mandatory references.
> -        """
> -
>      def destroySelf(break_references=False):
>          """Delete the specified branch.
>  
> 
> === modified file 'lib/lp/code/interfaces/gitjob.py'
> --- lib/lp/code/interfaces/gitjob.py	2015-03-12 15:21:27 +0000
> +++ lib/lp/code/interfaces/gitjob.py	2015-05-19 11:33:34 +0000
> @@ -9,6 +9,8 @@
>      'IGitJob',
>      'IGitRefScanJob',
>      'IGitRefScanJobSource',
> +    'IReclaimGitRepositorySpaceJob',
> +    'IReclaimGitRepositorySpaceJobSource',
>      ]
>  
>  from lazr.restful.fields import Reference
> @@ -16,6 +18,7 @@
>      Attribute,
>      Interface,
>      )
> +from zope.schema import Text
>  
>  from lp import _
>  from lp.code.interfaces.gitrepository import IGitRepository
> @@ -35,7 +38,7 @@
>  
>      repository = Reference(
>          title=_("The Git repository to use for this job."),
> -        schema=IGitRepository, required=True, readonly=True)
> +        schema=IGitRepository, required=False, readonly=True)
>  
>      metadata = Attribute(_("A dict of data about the job."))
>  
> @@ -51,3 +54,24 @@
>  
>          :param repository: The database repository to scan.
>          """
> +
> +
> +class IReclaimGitRepositorySpaceJob(IRunnableJob):
> +    """A Job that deletes a repository from storage after it has been
> +    deleted from the database."""
> +
> +    repository_path = Text(
> +        title=_("The storage path of the now-deleted repository."))
> +
> +
> +class IReclaimGitRepositorySpaceJobSource(IJobSource):
> +
> +    def create(repository_name, repository_path):
> +        """Construct a new object that implements
> +        IReclaimGitRepositorySpaceJob.
> +
> +        :param repository_name: The unique name of the repository to remove
> +            from storage.
> +        :param repository_path: The storage path of the repository to remove
> +            from storage.
> +        """
> 
> === modified file 'lib/lp/code/interfaces/gitrepository.py'
> --- lib/lp/code/interfaces/gitrepository.py	2015-05-14 13:57:51 +0000
> +++ lib/lp/code/interfaces/gitrepository.py	2015-05-19 11:33:34 +0000
> @@ -449,6 +449,18 @@
>          and their subscriptions.
>          """
>  
> +    # XXX cjwatson 2015-04-16: These names are too awful to set in stone by
> +    # exporting them on the webservice; find better names before exporting.
> +    landing_targets = Attribute(
> +        "A collection of the merge proposals where this repository is the "
> +        "source.")
> +    landing_candidates = Attribute(
> +        "A collection of the merge proposals where this repository is the "
> +        "target.")
> +    dependent_landings = Attribute(
> +        "A collection of the merge proposals that are dependent on this "
> +        "repository.")
> +
>      def isRepositoryMergeable(other):
>          """Is the other repository mergeable into this one (or vice versa)?"""
>  
> @@ -532,10 +544,35 @@
>      def setTarget(target, user):
>          """Set the target of the repository."""
>  
> +    @export_read_operation()
> +    @operation_for_version("devel")
> +    def canBeDeleted():
> +        """Can this repository be deleted in its current state?
> +
> +        A repository is considered deletable if it is not linked to any
> +        merge proposals.
> +        """
> +
> +    def getDeletionRequirements():
> +        """Determine what is required to delete this branch.
> +
> +        :return: a dict of {object: (operation, reason)}, where object is the
> +            object that must be deleted or altered, operation is either
> +            "delete" or "alter", and reason is a string explaining why the
> +            object needs to be touched.
> +        """
> +
> +    @call_with(break_references=True)
>      @export_destructor_operation()
>      @operation_for_version("devel")
> -    def destroySelf():
> -        """Delete the specified repository."""
> +    def destroySelf(break_references=False):
> +        """Delete the specified repository.
> +
> +        :param break_references: If supplied, break any references to this
> +            repository by deleting items with mandatory references and
> +            NULLing other references.
> +        :raise: CannotDeleteGitRepository if the repository cannot be deleted.
> +        """
>  
>  
>  class IGitRepository(IGitRepositoryView, IGitRepositoryModerateAttributes,
> 
> === modified file 'lib/lp/code/model/branch.py'
> --- lib/lp/code/model/branch.py	2015-04-30 21:49:59 +0000
> +++ lib/lp/code/model/branch.py	2015-05-19 11:33:34 +0000
> @@ -1250,10 +1250,6 @@
>                  datetime.now(pytz.timezone('UTC'))
>                  + increment * 2 ** (self.mirror_failures - 1))
>  
> -    def destroySelfBreakReferences(self):
> -        """See `IBranch`."""
> -        return self.destroySelf(break_references=True)
> -
>      def _deleteBranchSubscriptions(self):
>          """Delete subscriptions for this branch prior to deleting branch."""
>          subscriptions = Store.of(self).find(
> 
> === modified file 'lib/lp/code/model/gitjob.py'
> --- lib/lp/code/model/gitjob.py	2015-05-14 09:46:25 +0000
> +++ lib/lp/code/model/gitjob.py	2015-05-19 11:33:34 +0000
> @@ -7,6 +7,7 @@
>      'GitJob',
>      'GitJobType',
>      'GitRefScanJob',
> +    'ReclaimGitRepositorySpaceJob',
>      ]
>  
>  from lazr.delegates import delegates
> @@ -19,6 +20,7 @@
>      Int,
>      JSON,
>      Reference,
> +    SQL,
>      Store,
>      )
>  from zope.interface import (
> @@ -32,6 +34,8 @@
>      IGitJob,
>      IGitRefScanJob,
>      IGitRefScanJobSource,
> +    IReclaimGitRepositorySpaceJob,
> +    IReclaimGitRepositorySpaceJobSource,
>      )
>  from lp.services.config import config
>  from lp.services.database.enumcol import EnumCol
> @@ -63,6 +67,13 @@
>          This job scans a repository for its current list of references.
>          """)
>  
> +    RECLAIM_REPOSITORY_SPACE = DBItem(1, """
> +        Reclaim repository space
> +
> +        This job removes a repository that has been deleted from the
> +        database from storage.
> +        """)
> +
>  
>  class GitJob(StormBase):
>      """See `IGitJob`."""
> @@ -74,7 +85,7 @@
>      job_id = Int(name='job', primary=True, allow_none=False)
>      job = Reference(job_id, 'Job.id')
>  
> -    repository_id = Int(name='repository', allow_none=False)
> +    repository_id = Int(name='repository', allow_none=True)
>      repository = Reference(repository_id, 'GitRepository.id')
>  
>      job_type = EnumCol(enum=GitJobType, notNull=True)
> @@ -97,7 +108,8 @@
>          self.repository = repository
>          self.job_type = job_type
>          self.metadata = metadata
> -        self.metadata["repository_name"] = repository.unique_name
> +        if repository is not None:
> +            self.metadata["repository_name"] = repository.unique_name
>  
>      def makeDerived(self):
>          return GitJobDerived.makeSubclass(self)
> @@ -205,3 +217,43 @@
>              log.info(
>                  "Skipping repository %s because it has been deleted." %
>                  self._cached_repository_name)
> +
> +
> +class ReclaimGitRepositorySpaceJob(GitJobDerived):
> +    """A Job that deletes a repository from storage after it has been
> +    deleted from the database."""
> +
> +    implements(IReclaimGitRepositorySpaceJob)
> +
> +    classProvides(IReclaimGitRepositorySpaceJobSource)
> +    class_job_type = GitJobType.RECLAIM_REPOSITORY_SPACE
> +
> +    config = config.IReclaimGitRepositorySpaceJobSource
> +
> +    @classmethod
> +    def create(cls, repository_name, repository_path):
> +        "See `IReclaimGitRepositorySpaceJobSource`."""
> +        metadata = {
> +            "repository_name": repository_name,
> +            "repository_path": repository_path,
> +            }
> +        # The GitJob has a repository of None, as there is no repository
> +        # left in the database to refer to.
> +        start = SQL("CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + '7 days'")
> +        git_job = GitJob(
> +            None, cls.class_job_type, metadata, scheduled_start=start)

Does scheduled_start actually work with celery? If not, we probably need a non-celery runner for these.

> +        job = cls(git_job)
> +        job.celeryRunOnCommit()
> +        return job
> +
> +    def __init__(self, git_job):
> +        super(ReclaimGitRepositorySpaceJob, self).__init__(git_job)
> +        self._hosting_client = GitHostingClient(
> +            config.codehosting.internal_git_api_endpoint)

Any reason not to do this in run()?

> +
> +    @property
> +    def repository_path(self):
> +        return self.metadata["repository_path"]
> +
> +    def run(self):
> +        self._hosting_client.delete(self.repository_path, logger=log)
> 
> === modified file 'lib/lp/code/model/gitrepository.py'
> --- lib/lp/code/model/gitrepository.py	2015-05-14 13:57:51 +0000
> +++ lib/lp/code/model/gitrepository.py	2015-05-19 11:33:34 +0000
> @@ -20,6 +20,7 @@
>      Coalesce,
>      Insert,
>      Join,
> +    Not,
>      Or,
>      Select,
>      SQL,
> @@ -39,8 +40,12 @@
>  from zope.component import getUtility
>  from zope.interface import implements
>  from zope.security.interfaces import Unauthorized
> -from zope.security.proxy import removeSecurityProxy
> +from zope.security.proxy import (
> +    ProxyFactory,
> +    removeSecurityProxy,
> +    )
>  
> +from lp import _ as msg
>  from lp.app.enums import (
>      InformationType,
>      PRIVATE_INFORMATION_TYPES,
> @@ -58,9 +63,13 @@
>  from lp.app.interfaces.services import IService
>  from lp.code.enums import GitObjectType
>  from lp.code.errors import (
> +    CannotDeleteGitRepository,
>      GitDefaultConflict,
>      GitTargetError,
>      )
> +from lp.code.interfaces.branchmergeproposal import (
> +    BRANCH_MERGE_PROPOSAL_FINAL_STATES,
> +    )
>  from lp.code.interfaces.gitcollection import (
>      IAllGitRepositories,
>      IGitCollection,
> @@ -78,6 +87,7 @@
>      )
>  from lp.code.interfaces.revision import IRevisionSet
>  from lp.code.mail.branch import send_git_repository_modified_notifications
> +from lp.code.model.branchmergeproposal import BranchMergeProposal
>  from lp.code.model.gitref import GitRef
>  from lp.code.model.gitsubscription import GitSubscription
>  from lp.registry.enums import PersonVisibility
> @@ -756,6 +766,31 @@
>              recipients.add(subscription.person, subscription, rationale)
>          return recipients
>  
> +    @property
> +    def landing_targets(self):
> +        """See `IGitRepository`."""
> +        return Store.of(self).find(
> +            BranchMergeProposal,
> +            BranchMergeProposal.source_git_repository == self)
> +
> +    @property
> +    def landing_candidates(self):
> +        """See `IGitRepository`."""
> +        return Store.of(self).find(
> +            BranchMergeProposal,
> +            BranchMergeProposal.target_git_repository == self,
> +            Not(BranchMergeProposal.queue_status.is_in(
> +                BRANCH_MERGE_PROPOSAL_FINAL_STATES)))
> +
> +    @property
> +    def dependent_landings(self):
> +        """See `IGitRepository`."""
> +        return Store.of(self).find(
> +            BranchMergeProposal,
> +            BranchMergeProposal.prerequisite_git_repository,
> +            Not(BranchMergeProposal.queue_status.is_in(
> +                BRANCH_MERGE_PROPOSAL_FINAL_STATES)))
> +
>      def isRepositoryMergeable(self, other):
>          """See `IGitRepository`."""
>          return self.namespace.areRepositoriesMergeable(other.namespace)
> @@ -775,8 +810,164 @@
>              Job._status.is_in([JobStatus.WAITING, JobStatus.RUNNING]))
>          return not jobs.is_empty()
>  
> -    def destroySelf(self):
> -        raise NotImplementedError
> +    def canBeDeleted(self):
> +        """See `IGitRepository`."""
> +        # Can't delete if the repository is associated with anything.
> +        return len(self.getDeletionRequirements()) == 0
> +
> +    def _getDeletionRequirements(self):
> +        """Determine what operations must be performed to delete this branch.
> +
> +        Two dictionaries are returned, one for items that must be deleted,
> +        one for items that must be altered.  The item in question is the
> +        key, and the value is a user-facing string explaining why the item
> +        is affected.
> +
> +        As well as the dictionaries, this method returns two list of callables
> +        that may be called to perform the alterations and deletions needed.
> +        """
> +        alteration_operations = []
> +        deletion_operations = []
> +        # Merge proposals require their source and target repositories to
> +        # exist.
> +        for merge_proposal in self.landing_targets:
> +            deletion_operations.append(
> +                DeletionCallable(
> +                    merge_proposal,
> +                    msg("This repository is the source repository of this "
> +                        "merge proposal."),
> +                    merge_proposal.deleteProposal))
> +        # Cannot use self.landing_candidates, because it ignores merged
> +        # merge proposals.
> +        for merge_proposal in BranchMergeProposal.selectBy(
> +            target_git_repository=self):
> +            deletion_operations.append(
> +                DeletionCallable(
> +                    merge_proposal,
> +                    msg("This repository is the target repository of this "
> +                        "merge proposal."),
> +                    merge_proposal.deleteProposal))
> +        for merge_proposal in BranchMergeProposal.selectBy(
> +            prerequisite_git_repository=self):
> +            alteration_operations.append(
> +                ClearPrerequisiteRepository(merge_proposal))
> +
> +        return (alteration_operations, deletion_operations)
> +
> +    def getDeletionRequirements(self):
> +        """See `IGitRepository`."""
> +        alteration_operations, deletion_operations = (
> +            self._getDeletionRequirements())
> +        result = {
> +            operation.affected_object: ("alter", operation.rationale)
> +            for operation in alteration_operations}
> +        # Deletion entries should overwrite alteration entries.
> +        result.update({
> +            operation.affected_object: ("delete", operation.rationale)
> +            for operation in deletion_operations})
> +        return result
> +
> +    def _breakReferences(self):
> +        """Break all external references to this repository.
> +
> +        NULLable references will be NULLed.  References which are not NULLable
> +        will cause the item holding the reference to be deleted.
> +
> +        This function is guaranteed to perform the operations predicted by
> +        getDeletionRequirements, because it uses the same backing function.
> +        """
> +        alteration_operations, deletion_operations = (
> +            self._getDeletionRequirements())
> +        for operation in alteration_operations:
> +            operation()
> +        for operation in deletion_operations:
> +            operation()
> +        Store.of(self).flush()
> +
> +    def _deleteRepositorySubscriptions(self):
> +        """Delete subscriptions for this repository prior to deleting it."""
> +        subscriptions = Store.of(self).find(
> +            GitSubscription, GitSubscription.repository == self)
> +        subscriptions.remove()
> +
> +    def _deleteJobs(self):
> +        """Delete jobs for this repository prior to deleting it.
> +
> +        This deletion includes `GitJob`s associated with the branch.
> +        """
> +        # Circular import.
> +        from lp.code.model.gitjob import GitJob
> +
> +        # Remove GitJobs.
> +        affected_jobs = Select(
> +            [GitJob.job_id],
> +            And(GitJob.job == Job.id, GitJob.repository == self))
> +        Store.of(self).find(Job, Job.id.is_in(affected_jobs)).remove()
> +
> +    def destroySelf(self, break_references=False):
> +        """See `IGitRepository`."""
> +        # Circular import.
> +        from lp.code.interfaces.gitjob import (
> +            IReclaimGitRepositorySpaceJobSource,
> +            )
> +
> +        if break_references:
> +            self._breakReferences()
> +        if not self.canBeDeleted():
> +            raise CannotDeleteGitRepository(
> +                "Cannot delete Git repository: %s" % self.unique_name)
> +
> +        self.refs.remove()
> +        self._deleteRepositorySubscriptions()
> +        self._deleteJobs()
> +
> +        # Now destroy the repository.
> +        repository_name = self.unique_name
> +        repository_path = self.getInternalPath()
> +        Store.of(self).remove(self)
> +        # And now create a job to remove the repository from storage when
> +        # it's done.
> +        getUtility(IReclaimGitRepositorySpaceJobSource).create(
> +            repository_name, repository_path)
> +
> +
> +class DeletionOperation:
> +    """Represent an operation to perform as part of branch deletion."""
> +
> +    def __init__(self, affected_object, rationale):
> +        self.affected_object = ProxyFactory(affected_object)
> +        self.rationale = rationale
> +
> +    def __call__(self):
> +        """Perform the deletion operation."""
> +        raise NotImplementedError(DeletionOperation.__call__)
> +
> +
> +class DeletionCallable(DeletionOperation):
> +    """Deletion operation that invokes a callable."""
> +
> +    def __init__(self, affected_object, rationale, func):
> +        super(DeletionCallable, self).__init__(affected_object, rationale)
> +        self.func = func
> +
> +    def __call__(self):
> +        self.func()
> +
> +
> +class ClearPrerequisiteRepository(DeletionOperation):
> +    """Delete operation that clears a merge proposal's prerequisite
> +    repository."""
> +
> +    def __init__(self, merge_proposal):
> +        DeletionOperation.__init__(
> +            self, merge_proposal,
> +            msg("This repository is the prerequisite repository of this merge "
> +                "proposal."))
> +
> +    def __call__(self):
> +        self.affected_object.prerequisite_git_repository = None
> +        self.affected_object.prerequisite_git_path = None
> +        self.affected_object.prerequisite_git_commit_sha1 = None
>  
>  
>  class GitRepositorySet:
> 
> === modified file 'lib/lp/code/model/tests/test_gitjob.py'
> --- lib/lp/code/model/tests/test_gitjob.py	2015-05-14 13:57:51 +0000
> +++ lib/lp/code/model/tests/test_gitjob.py	2015-05-19 11:33:34 +0000
> @@ -16,18 +16,22 @@
>      MatchesSetwise,
>      MatchesStructure,
>      )
> +from zope.security.proxy import removeSecurityProxy
>  
>  from lp.code.enums import GitObjectType
>  from lp.code.interfaces.gitjob import (
>      IGitJob,
>      IGitRefScanJob,
> +    IReclaimGitRepositorySpaceJob,
>      )
>  from lp.code.model.gitjob import (
>      GitJob,
>      GitJobDerived,
>      GitJobType,
>      GitRefScanJob,
> +    ReclaimGitRepositorySpaceJob,
>      )
> +from lp.services.database.constants import UTC_NOW
>  from lp.testing import (
>      TestCaseWithFactory,
>      time_counter,
> @@ -65,7 +69,10 @@
>          self.assertIsNone(derived.getOopsMailController("x"))
>  
>  
> -class TestGitRefScanJobMixin:
> +class TestGitRefScanJob(TestCaseWithFactory):
> +    """Tests for `GitRefScanJob`."""
> +
> +    layer = LaunchpadZopelessLayer
>  
>      @staticmethod
>      def makeFakeRefs(paths):
> @@ -107,12 +114,6 @@
>              for path in paths]
>          self.assertThat(refs, MatchesSetwise(*matchers))
>  
> -
> -class TestGitRefScanJob(TestGitRefScanJobMixin, TestCaseWithFactory):
> -    """Tests for `GitRefScanJob`."""
> -
> -    layer = LaunchpadZopelessLayer
> -
>      def test_provides_interface(self):
>          # `GitRefScanJob` objects provide `IGitRefScanJob`.
>          repository = self.factory.makeGitRepository()
> @@ -156,5 +157,64 @@
>          self.assertEqual([], list(repository.refs))
>  
>  
> -# XXX cjwatson 2015-03-12: We should test that the job works via Celery too,
> +class TestReclaimGitRepositorySpaceJob(TestCaseWithFactory):
> +    """Tests for `ReclaimGitRepositorySpaceJob`."""
> +
> +    layer = LaunchpadZopelessLayer
> +
> +    def test_provides_interface(self):
> +        # `ReclaimGitRepositorySpaceJob` objects provide
> +        # `IReclaimGitRepositorySpaceJob`.
> +        self.assertProvides(
> +            ReclaimGitRepositorySpaceJob.create("/~owner/+git/gone", "1"),
> +            IReclaimGitRepositorySpaceJob)
> +
> +    def test___repr__(self):
> +        # `ReclaimGitRepositorySpaceJob` objects have an informative
> +        # __repr__.
> +        name = "/~owner/+git/gone"
> +        job = ReclaimGitRepositorySpaceJob.create(name, "1")
> +        self.assertEqual(
> +            "<ReclaimGitRepositorySpaceJob for %s>" % name, repr(job))
> +
> +    def test_scheduled_in_future(self):
> +        # A freshly created ReclaimGitRepositorySpaceJob is scheduled to run
> +        # in a week's time.
> +        job = ReclaimGitRepositorySpaceJob.create("/~owner/+git/gone", "1")
> +        self.assertEqual(
> +            timedelta(days=7), job.job.scheduled_start - job.job.date_created)
> +
> +    def test_stores_name_and_path(self):
> +        # An instance of ReclaimGitRepositorySpaceJob stores the name and
> +        # path of the repository that has been deleted.
> +        name = "/~owner/+git/gone"
> +        path = "1"
> +        job = ReclaimGitRepositorySpaceJob.create(name, path)
> +        self.assertEqual(name, job._cached_repository_name)
> +        self.assertEqual(path, job.repository_path)
> +
> +    def makeJobReady(self, job):
> +        """Force `job` to be scheduled to run now.
> +
> +        New `ReclaimGitRepositorySpaceJob`s are scheduled to run a week
> +        after creation, so to be able to test running the job we have to
> +        force them to be scheduled now.
> +        """
> +        removeSecurityProxy(job).job.scheduled_start = UTC_NOW
> +
> +    def test_run(self):
> +        # Running a job to reclaim space sends a request to the hosting
> +        # service.
> +        name = "/~owner/+git/gone"
> +        path = "1"
> +        job = ReclaimGitRepositorySpaceJob.create(name, path)
> +        self.makeJobReady(job)
> +        [job] = list(ReclaimGitRepositorySpaceJob.iterReady())
> +        with dbuser("reclaim-branch-space"):
> +            job._hosting_client.delete = FakeMethod()
> +            job.run()
> +        self.assertEqual([(path,)], job._hosting_client.delete.extract_args())
> +
> +
> +# 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	2015-05-14 13:57:51 +0000
> +++ lib/lp/code/model/tests/test_gitrepository.py	2015-05-19 11:33:34 +0000
> @@ -14,6 +14,8 @@
>  from bzrlib import urlutils
>  from lazr.lifecycle.event import ObjectModifiedEvent
>  from lazr.lifecycle.snapshot import Snapshot
> +from sqlobject import SQLObjectNotFound
> +from storm.store import Store
>  import transaction
>  import pytz
>  from testtools.matchers import (
> @@ -27,6 +29,7 @@
>  from zope.security.interfaces import Unauthorized
>  from zope.security.proxy import removeSecurityProxy
>  
> +from lp import _
>  from lp.app.enums import (
>      InformationType,
>      PRIVATE_INFORMATION_TYPES,
> @@ -40,6 +43,7 @@
>      GitObjectType,
>      )
>  from lp.code.errors import (
> +    CannotDeleteGitRepository,
>      GitRepositoryCreatorNotMemberOfOwnerTeam,
>      GitRepositoryCreatorNotOwner,
>      GitRepositoryExists,
> @@ -47,6 +51,7 @@
>      )
>  from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
>  from lp.code.interfaces.gitjob import IGitRefScanJobSource
> +from lp.code.interfaces.gitlookup import IGitLookup
>  from lp.code.interfaces.gitnamespace import (
>      IGitNamespacePolicy,
>      IGitNamespaceSet,
> @@ -56,7 +61,19 @@
>      IGitRepositorySet,
>      )
>  from lp.code.interfaces.revision import IRevisionSet
> -from lp.code.model.gitrepository import GitRepository
> +from lp.code.model.branchmergeproposal import BranchMergeProposal
> +from lp.code.model.codereviewcomment import CodeReviewComment
> +from lp.code.model.gitjob import (
> +    GitJob,
> +    GitJobType,
> +    ReclaimGitRepositorySpaceJob,
> +    )
> +from lp.code.model.gitrepository import (
> +    ClearPrerequisiteRepository,
> +    DeletionCallable,
> +    DeletionOperation,
> +    GitRepository,
> +    )
>  from lp.code.xmlrpc.git import GitAPI
>  from lp.registry.enums import (
>      BranchSharingPolicy,
> @@ -84,6 +101,7 @@
>      ANONYMOUS,
>      api_url,
>      celebrity_logged_in,
> +    login_person,
>      person_logged_in,
>      TestCaseWithFactory,
>      verifyObject,
> @@ -93,6 +111,7 @@
>  from lp.testing.layers import (
>      DatabaseFunctionalLayer,
>      LaunchpadFunctionalLayer,
> +    LaunchpadZopelessLayer,
>      )
>  from lp.testing.pages import webservice_for_person
>  
> @@ -284,6 +303,305 @@
>              repository.getRepositoryIdentities())
>  
>  
> +class TestGitRepositoryDeletion(TestCaseWithFactory):
> +    """Test the different cases that make a repository deletable or not."""
> +
> +    layer = LaunchpadZopelessLayer
> +
> +    def setUp(self):
> +        super(TestGitRepositoryDeletion, self).setUp()
> +        self.user = self.factory.makePerson()
> +        self.project = self.factory.makeProduct(owner=self.user)
> +        self.repository = self.factory.makeGitRepository(
> +            name=u"to-delete", owner=self.user, target=self.project)
> +        [self.ref] = self.factory.makeGitRefs(repository=self.repository)
> +        # The owner of the repository is subscribed to the repository when
> +        # it is created.  The tests here assume no initial connections, so
> +        # unsubscribe the repository owner here.
> +        self.repository.unsubscribe(
> +            self.repository.owner, self.repository.owner)
> +        # Make sure that the tests all flush the database changes.
> +        self.addCleanup(Store.of(self.repository).flush)
> +        login_person(self.user)
> +
> +    def test_deletable(self):
> +        # A newly created repository can be deleted without any problems.
> +        self.assertTrue(
> +            self.repository.canBeDeleted(),
> +            "A newly created repository should be able to be deleted.")
> +        repository_id = self.repository.id
> +        self.repository.destroySelf()
> +        self.assertIsNone(
> +            getUtility(IGitLookup).get(repository_id),
> +            "The repository has not been deleted.")
> +
> +    def test_subscription_does_not_disable_deletion(self):
> +        # A repository that has a subscription can be deleted.
> +        self.repository.subscribe(
> +            self.user, BranchSubscriptionNotificationLevel.NOEMAIL, None,
> +            CodeReviewNotificationLevel.NOEMAIL, self.user)
> +        self.assertTrue(self.repository.canBeDeleted())
> +
> +    def test_landing_target_disables_deletion(self):
> +        # A repository with a landing target cannot be deleted.
> +        [merge_target] = self.factory.makeGitRefs(
> +            name=u"landing-target", owner=self.user, target=self.project)
> +        self.ref.addLandingTarget(self.user, merge_target)
> +        self.assertFalse(
> +            self.repository.canBeDeleted(),
> +            "A repository with a landing target is not deletable.")
> +        self.assertRaises(
> +            CannotDeleteGitRepository, self.repository.destroySelf)
> +
> +    def test_landing_candidate_disables_deletion(self):
> +        # A repository with a landing candidate cannot be deleted.
> +        [merge_source] = self.factory.makeGitRefs(
> +            name=u"landing-candidate", owner=self.user, target=self.project)
> +        merge_source.addLandingTarget(self.user, self.ref)
> +        self.assertFalse(
> +            self.repository.canBeDeleted(),
> +             "A repository with a landing candidate is not deletable.")
> +        self.assertRaises(
> +            CannotDeleteGitRepository, self.repository.destroySelf)
> +
> +    def test_prerequisite_repository_disables_deletion(self):
> +        # A repository that is a prerequisite repository cannot be deleted.
> +        [merge_source] = self.factory.makeGitRefs(
> +            name=u"landing-candidate", owner=self.user, target=self.project)
> +        [merge_target] = self.factory.makeGitRefs(
> +            name=u"landing-target", owner=self.user, target=self.project)
> +        merge_source.addLandingTarget(self.user, merge_target, self.ref)
> +        self.assertFalse(
> +            self.repository.canBeDeleted(),
> +            "A repository with a prerequisite target is not deletable.")
> +        self.assertRaises(
> +            CannotDeleteGitRepository, self.repository.destroySelf)
> +
> +    def test_related_GitJobs_deleted(self):
> +        # A repository with an associated job will delete those jobs.
> +        repository = self.factory.makeGitRepository()
> +        GitAPI(None, None).notify(repository.getInternalPath())
> +        store = Store.of(repository)
> +        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)
> +        self.assertEqual([], list(jobs))
> +
> +    def test_creates_job_to_reclaim_space(self):
> +        # When a repository is deleted from the database, a job is created
> +        # to remove the repository from disk as well.
> +        repository = self.factory.makeGitRepository()
> +        repository_path = repository.getInternalPath()
> +        store = Store.of(repository)
> +        repository.destroySelf()
> +        jobs = store.find(
> +            GitJob,
> +            GitJob.job_type == GitJobType.RECLAIM_REPOSITORY_SPACE)
> +        self.assertEqual(
> +            [repository_path],
> +            [ReclaimGitRepositorySpaceJob(job).repository_path
> +             for job in jobs])
> +
> +    def test_destroySelf_with_inline_comments_draft(self):
> +        # Draft inline comments related to a deleted repository (source or
> +        # target MP repository) also get removed.
> +        merge_proposal = self.factory.makeBranchMergeProposalForGit(
> +            registrant=self.user, target_ref=self.ref)
> +        preview_diff = self.factory.makePreviewDiff(
> +            merge_proposal=merge_proposal)
> +        transaction.commit()
> +        merge_proposal.saveDraftInlineComment(
> +            previewdiff_id=preview_diff.id,
> +            person=self.user,
> +            comments={"1": "Should vanish."})
> +        self.repository.destroySelf(break_references=True)
> +
> +    def test_destroySelf_with_inline_comments_published(self):
> +        # Published inline comments related to a deleted repository (source
> +        # or target MP repository) also get removed.
> +        merge_proposal = self.factory.makeBranchMergeProposalForGit(
> +            registrant=self.user, target_ref=self.ref)
> +        preview_diff = self.factory.makePreviewDiff(
> +            merge_proposal=merge_proposal)
> +        transaction.commit()
> +        merge_proposal.createComment(
> +            owner=self.user,
> +            subject="Delete me!",
> +            previewdiff_id=preview_diff.id,
> +            inline_comments={"1": "Must disappear."},
> +        )
> +        self.repository.destroySelf(break_references=True)
> +
> +
> +class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
> +    """Test determination and application of repository deletion
> +    consequences."""
> +
> +    layer = LaunchpadZopelessLayer
> +
> +    def setUp(self):
> +        super(TestGitRepositoryDeletionConsequences, self).setUp(
> +            user="test@xxxxxxxxxxxxx")
> +        self.repository = self.factory.makeGitRepository()
> +        [self.ref] = self.factory.makeGitRefs(repository=self.repository)
> +        # The owner of the repository is subscribed to the repository when
> +        # it is created.  The tests here assume no initial connections, so
> +        # unsubscribe the repository owner here.
> +        self.repository.unsubscribe(
> +            self.repository.owner, self.repository.owner)
> +
> +    def test_plain_repository(self):
> +        # A fresh repository has no deletion requirements.
> +        self.assertEqual({}, self.repository.getDeletionRequirements())
> +
> +    def makeMergeProposals(self):
> +        # Produce a merge proposal for testing purposes.
> +        [merge_target] = self.factory.makeGitRefs(target=self.ref.target)
> +        [merge_prerequisite] = self.factory.makeGitRefs(target=self.ref.target)
> +        # Remove the implicit subscriptions.
> +        merge_target.repository.unsubscribe(
> +            merge_target.owner, merge_target.owner)
> +        merge_prerequisite.repository.unsubscribe(
> +            merge_prerequisite.owner, merge_prerequisite.owner)
> +        merge_proposal1 = self.ref.addLandingTarget(
> +            self.ref.owner, merge_target, merge_prerequisite)
> +        # Disable this merge proposal, to allow creating a new identical one.
> +        lp_admins = getUtility(ILaunchpadCelebrities).admin
> +        merge_proposal1.rejectBranch(lp_admins, "null:")
> +        merge_proposal2 = self.ref.addLandingTarget(
> +            self.ref.owner, merge_target, merge_prerequisite)
> +        return merge_proposal1, merge_proposal2
> +
> +    def test_repository_with_merge_proposal(self):
> +        # Ensure that deletion requirements with a merge proposal are right.
> +        #
> +        # Each repository related to the merge proposal is tested to ensure
> +        # it produces a unique, correct result.
> +        merge_proposal1, merge_proposal2 = self.makeMergeProposals()
> +        self.assertEqual(
> +            {
> +                merge_proposal1:
> +                ("delete",
> +                 _("This repository is the source repository of this merge "
> +                   "proposal.")),
> +                merge_proposal2:
> +                ("delete",
> +                 _("This repository is the source repository of this merge "
> +                   "proposal.")),
> +                },
> +            self.repository.getDeletionRequirements())
> +        target = merge_proposal1.target_git_repository
> +        self.assertEqual(
> +            {
> +                merge_proposal1:
> +                ("delete",
> +                 _("This repository is the target repository of this merge "
> +                   "proposal.")),
> +                merge_proposal2:
> +                ("delete",
> +                 _("This repository is the target repository of this merge "
> +                   "proposal.")),
> +                },
> +            target.getDeletionRequirements())
> +        prerequisite = merge_proposal1.prerequisite_git_repository
> +        self.assertEqual(
> +            {
> +                merge_proposal1:
> +                ("alter",
> +                 _("This repository is the prerequisite repository of this "
> +                   "merge proposal.")),
> +                merge_proposal2:
> +                ("alter",
> +                 _("This repository is the prerequisite repository of this "
> +                   "merge proposal.")),
> +                },
> +            prerequisite.getDeletionRequirements())
> +
> +    def test_delete_merge_proposal_source(self):
> +        # Merge proposal source repositories can be deleted with
> +        # break_references.
> +        merge_proposal1, merge_proposal2 = self.makeMergeProposals()
> +        merge_proposal1_id = merge_proposal1.id
> +        BranchMergeProposal.get(merge_proposal1_id)
> +        self.repository.destroySelf(break_references=True)
> +        self.assertRaises(
> +            SQLObjectNotFound, BranchMergeProposal.get, merge_proposal1_id)
> +
> +    def test_delete_merge_proposal_target(self):
> +        # Merge proposal target repositories can be deleted with
> +        # break_references.
> +        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)
> +        self.assertRaises(SQLObjectNotFound,
> +            BranchMergeProposal.get, merge_proposal1_id)
> +
> +    def test_delete_merge_proposal_prerequisite(self):
> +        # 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)
> +        self.assertIsNone(merge_proposal1.prerequisite_git_repository)
> +
> +    def test_delete_source_CodeReviewComment(self):
> +        # Deletion of source repositories that have CodeReviewComments works.
> +        comment = self.factory.makeCodeReviewComment(git=True)
> +        comment_id = comment.id
> +        repository = comment.branch_merge_proposal.source_git_repository
> +        repository.destroySelf(break_references=True)
> +        self.assertRaises(
> +            SQLObjectNotFound, CodeReviewComment.get, comment_id)
> +
> +    def test_delete_target_CodeReviewComment(self):
> +        # Deletion of target repositories that have CodeReviewComments works.
> +        comment = self.factory.makeCodeReviewComment(git=True)
> +        comment_id = comment.id
> +        repository = comment.branch_merge_proposal.target_git_repository
> +        repository.destroySelf(break_references=True)
> +        self.assertRaises(
> +            SQLObjectNotFound, CodeReviewComment.get, comment_id)
> +
> +    def test_sourceBranchWithCodeReviewVoteReference(self):
> +        # break_references handles CodeReviewVoteReference source repository.
> +        merge_proposal = self.factory.makeBranchMergeProposalForGit()
> +        merge_proposal.nominateReviewer(
> +            self.factory.makePerson(), self.factory.makePerson())
> +        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)
> +
> +    def test_ClearPrerequisiteRepository(self):
> +        # ClearPrerequisiteRepository.__call__ must clear the prerequisite
> +        # repository.
> +        merge_proposal = removeSecurityProxy(self.makeMergeProposals()[0])
> +        with person_logged_in(
> +                merge_proposal.prerequisite_git_repository.owner):
> +            ClearPrerequisiteRepository(merge_proposal)()
> +        self.assertIsNone(merge_proposal.prerequisite_git_repository)
> +
> +    def test_DeletionOperation(self):
> +        # DeletionOperation.__call__ is not implemented.
> +        self.assertRaises(NotImplementedError, DeletionOperation("a", "b"))
> +
> +    def test_DeletionCallable(self):
> +        # DeletionCallable must invoke the callable.
> +        merge_proposal = self.factory.makeBranchMergeProposalForGit()
> +        merge_proposal_id = merge_proposal.id
> +        DeletionCallable(
> +            merge_proposal, "blah", merge_proposal.deleteProposal)()
> +        self.assertRaises(
> +            SQLObjectNotFound, BranchMergeProposal.get, merge_proposal_id)
> +
> +
>  class TestGitRepositoryModifications(TestCaseWithFactory):
>      """Tests for Git repository modification notifications."""
>  
> 
> === modified file 'lib/lp/services/config/schema-lazr.conf'
> --- lib/lp/services/config/schema-lazr.conf	2015-04-22 16:00:09 +0000
> +++ lib/lp/services/config/schema-lazr.conf	2015-05-19 11:33:34 +0000
> @@ -1818,6 +1818,10 @@
>  module: lp.code.interfaces.branchjob
>  dbuser: reclaim-branch-space
>  
> +[IReclaimGitRepositorySpaceJobSource]
> +module: lp.code.interfaces.gitjob
> +dbuser: reclaim-branch-space
> +
>  [IRemoveArtifactSubscriptionsJobSource]
>  module: lp.registry.interfaces.sharingjob
>  dbuser: sharing-jobs
> 
> === modified file 'lib/lp/testing/factory.py'
> --- lib/lp/testing/factory.py	2015-05-19 00:41:13 +0000
> +++ lib/lp/testing/factory.py	2015-05-19 11:33:34 +0000
> @@ -1567,10 +1567,13 @@
>                  Diff.fromFile(StringIO(diff_text), len(diff_text)))
>  
>      def makePreviewDiff(self, conflicts=u'', merge_proposal=None,
> -                        date_created=None, size='small'):
> +                        date_created=None, size='small', git=False):
>          diff = self.makeDiff(size)
>          if merge_proposal is None:
> -            merge_proposal = self.makeBranchMergeProposal()
> +            if git:
> +                merge_proposal = self.makeBranchMergeProposalForGit()
> +            else:
> +                merge_proposal = self.makeBranchMergeProposal()
>          preview_diff = PreviewDiff()
>          preview_diff.branch_merge_proposal = merge_proposal
>          preview_diff.conflicts = conflicts
> @@ -2438,7 +2441,8 @@
>  
>      def makeCodeReviewComment(self, sender=None, subject=None, body=None,
>                                vote=None, vote_tag=None, parent=None,
> -                              merge_proposal=None, date_created=DEFAULT):
> +                              merge_proposal=None, date_created=DEFAULT,
> +                              git=False):
>          if sender is None:
>              sender = self.makePerson()
>          if subject is None:
> @@ -2448,6 +2452,9 @@
>          if merge_proposal is None:
>              if parent:
>                  merge_proposal = parent.branch_merge_proposal
> +            elif git:
> +                merge_proposal = self.makeBranchMergeProposalForGit(
> +                    registrant=sender)
>              else:
>                  merge_proposal = self.makeBranchMergeProposal(
>                      registrant=sender)
> @@ -2456,8 +2463,11 @@
>                  sender, subject, body, vote, vote_tag, parent,
>                  _date_created=date_created)
>  
> -    def makeCodeReviewVoteReference(self):
> -        bmp = removeSecurityProxy(self.makeBranchMergeProposal())
> +    def makeCodeReviewVoteReference(self, git=False):
> +        if git:
> +            bmp = removeSecurityProxy(self.makeBranchMergeProposalForGit())
> +        else:
> +            bmp = removeSecurityProxy(self.makeBranchMergeProposal())
>          candidate = self.makePerson()
>          return bmp.nominateReviewer(candidate, bmp.registrant)
>  
> 


-- 
https://code.launchpad.net/~cjwatson/launchpad/git-repository-delete/+merge/259487
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.


References