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

Proposed by Colin Watson
Status: Merged
Merged at revision: 17520
Proposed branch: lp:~cjwatson/launchpad/git-repository-delete
Merge into: lp:launchpad
Diff against target: 1203 lines (+760/-36)
14 files modified
database/schema/security.cfg (+1/-0)
lib/lp/code/configure.zcml (+12/-1)
lib/lp/code/errors.py (+11/-0)
lib/lp/code/githosting.py (+16/-1)
lib/lp/code/interfaces/branch.py (+2/-8)
lib/lp/code/interfaces/gitjob.py (+25/-1)
lib/lp/code/interfaces/gitrepository.py (+39/-2)
lib/lp/code/model/branch.py (+0/-4)
lib/lp/code/model/gitjob.py (+54/-2)
lib/lp/code/model/gitrepository.py (+194/-3)
lib/lp/code/model/tests/test_gitjob.py (+68/-8)
lib/lp/code/model/tests/test_gitrepository.py (+319/-1)
lib/lp/services/config/schema-lazr.conf (+4/-0)
lib/lp/testing/factory.py (+15/-5)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-repository-delete
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+259487@code.launchpad.net

Commit message

Implement the model/webservice side of Git repository deletion.

Description of the change

Implement the model/webservice side of Git repository deletion.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2015-05-19 00:07:44 +0000
3+++ database/schema/security.cfg 2015-05-19 11:33:34 +0000
4@@ -2039,6 +2039,7 @@
5 [reclaim-branch-space]
6 groups=script
7 public.branchjob = SELECT
8+public.gitjob = SELECT
9 public.job = SELECT, UPDATE
10 type=user
11
12
13=== modified file 'lib/lp/code/configure.zcml'
14--- lib/lp/code/configure.zcml 2015-04-22 14:52:10 +0000
15+++ lib/lp/code/configure.zcml 2015-05-19 11:33:34 +0000
16@@ -204,7 +204,9 @@
17 permission="launchpad.Edit"
18 interface="lp.code.interfaces.branchmergeproposal.IBranchMergeProposalEdit"
19 set_attributes="description whiteboard merged_revno commit_message
20- root_message_id prerequisite_branch"/>
21+ root_message_id prerequisite_branch
22+ prerequisite_git_repository prerequisite_git_path
23+ prerequisite_git_commit_sha1"/>
24 <require
25 permission="launchpad.AnyAllowedPerson"
26 interface="lp.code.interfaces.branchmergeproposal.IBranchMergeProposalAnyAllowedPerson"/>
27@@ -941,10 +943,19 @@
28 provides="lp.code.interfaces.gitjob.IGitRefScanJobSource">
29 <allow interface="lp.code.interfaces.gitjob.IGitRefScanJobSource" />
30 </securedutility>
31+ <securedutility
32+ component="lp.code.model.gitjob.ReclaimGitRepositorySpaceJob"
33+ provides="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJobSource">
34+ <allow interface="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJobSource" />
35+ </securedutility>
36 <class class="lp.code.model.gitjob.GitRefScanJob">
37 <allow interface="lp.code.interfaces.gitjob.IGitJob" />
38 <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
39 </class>
40+ <class class="lp.code.model.gitjob.ReclaimGitRepositorySpaceJob">
41+ <allow interface="lp.code.interfaces.gitjob.IGitJob" />
42+ <allow interface="lp.code.interfaces.gitjob.IReclaimGitRepositorySpaceJob" />
43+ </class>
44
45 <lp:help-folder folder="help" name="+help-code" />
46
47
48=== modified file 'lib/lp/code/errors.py'
49--- lib/lp/code/errors.py 2015-05-14 13:57:51 +0000
50+++ lib/lp/code/errors.py 2015-05-19 11:33:34 +0000
51@@ -20,6 +20,7 @@
52 'BuildNotAllowedForDistro',
53 'BranchMergeProposalExists',
54 'CannotDeleteBranch',
55+ 'CannotDeleteGitRepository',
56 'CannotHaveLinkedBranch',
57 'CannotUpgradeBranch',
58 'CannotUpgradeNonHosted',
59@@ -34,6 +35,7 @@
60 'GitRepositoryCreationForbidden',
61 'GitRepositoryCreatorNotMemberOfOwnerTeam',
62 'GitRepositoryCreatorNotOwner',
63+ 'GitRepositoryDeletionFault',
64 'GitRepositoryExists',
65 'GitRepositoryScanFault',
66 'GitTargetError',
67@@ -347,6 +349,11 @@
68 GitRepositoryCreationException.__init__(self, message)
69
70
71+@error_status(httplib.BAD_REQUEST)
72+class CannotDeleteGitRepository(Exception):
73+ """The Git repository cannot be deleted at this time."""
74+
75+
76 class GitRepositoryCreationForbidden(GitRepositoryCreationException):
77 """A visibility policy forbids Git repository creation.
78
79@@ -381,6 +388,10 @@
80 """Raised when there is a fault scanning a repository."""
81
82
83+class GitRepositoryDeletionFault(Exception):
84+ """Raised when there is a fault deleting a repository."""
85+
86+
87 class GitTargetError(Exception):
88 """Raised when there is an error determining a Git repository target."""
89
90
91=== modified file 'lib/lp/code/githosting.py'
92--- lib/lp/code/githosting.py 2015-04-30 17:10:28 +0000
93+++ lib/lp/code/githosting.py 2015-05-19 11:33:34 +0000
94@@ -15,6 +15,7 @@
95
96 from lp.code.errors import (
97 GitRepositoryCreationFault,
98+ GitRepositoryDeletionFault,
99 GitRepositoryScanFault,
100 )
101
102@@ -127,9 +128,23 @@
103 if response.status_code != 200:
104 raise GitRepositoryScanFault(
105 "Failed to get merge diff from Git repository: %s" %
106- unicode(e))
107+ response.text)
108 try:
109 return response.json()
110 except ValueError as e:
111 raise GitRepositoryScanFault(
112 "Failed to decode merge-diff response: %s" % unicode(e))
113+
114+ def delete(self, path, logger=None):
115+ """Delete a repository."""
116+ try:
117+ if logger is not None:
118+ logger.info("Deleting repository %s" % path)
119+ response = self._makeSession().delete(
120+ urljoin(self.endpoint, "/repo/%s" % path))
121+ except Exception as e:
122+ raise GitRepositoryDeletionFault(
123+ "Failed to delete Git repository: %s" % unicode(e))
124+ if response.status_code != 200:
125+ raise GitRepositoryDeletionFault(
126+ "Failed to delete Git repository: %s" % response.text)
127
128=== modified file 'lib/lp/code/interfaces/branch.py'
129--- lib/lp/code/interfaces/branch.py 2015-04-30 21:49:59 +0000
130+++ lib/lp/code/interfaces/branch.py 2015-05-19 11:33:34 +0000
131@@ -1,4 +1,4 @@
132-# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
133+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
134 # GNU Affero General Public License version 3 (see the file LICENSE).
135
136 """Branch interfaces."""
137@@ -1167,15 +1167,9 @@
138 branch.
139 """
140
141+ @call_with(break_references=True)
142 @export_destructor_operation()
143 @operation_for_version('beta')
144- def destroySelfBreakReferences():
145- """Delete the specified branch.
146-
147- BranchRevisions associated with this branch will also be deleted as
148- well as any items with mandatory references.
149- """
150-
151 def destroySelf(break_references=False):
152 """Delete the specified branch.
153
154
155=== modified file 'lib/lp/code/interfaces/gitjob.py'
156--- lib/lp/code/interfaces/gitjob.py 2015-03-12 15:21:27 +0000
157+++ lib/lp/code/interfaces/gitjob.py 2015-05-19 11:33:34 +0000
158@@ -9,6 +9,8 @@
159 'IGitJob',
160 'IGitRefScanJob',
161 'IGitRefScanJobSource',
162+ 'IReclaimGitRepositorySpaceJob',
163+ 'IReclaimGitRepositorySpaceJobSource',
164 ]
165
166 from lazr.restful.fields import Reference
167@@ -16,6 +18,7 @@
168 Attribute,
169 Interface,
170 )
171+from zope.schema import Text
172
173 from lp import _
174 from lp.code.interfaces.gitrepository import IGitRepository
175@@ -35,7 +38,7 @@
176
177 repository = Reference(
178 title=_("The Git repository to use for this job."),
179- schema=IGitRepository, required=True, readonly=True)
180+ schema=IGitRepository, required=False, readonly=True)
181
182 metadata = Attribute(_("A dict of data about the job."))
183
184@@ -51,3 +54,24 @@
185
186 :param repository: The database repository to scan.
187 """
188+
189+
190+class IReclaimGitRepositorySpaceJob(IRunnableJob):
191+ """A Job that deletes a repository from storage after it has been
192+ deleted from the database."""
193+
194+ repository_path = Text(
195+ title=_("The storage path of the now-deleted repository."))
196+
197+
198+class IReclaimGitRepositorySpaceJobSource(IJobSource):
199+
200+ def create(repository_name, repository_path):
201+ """Construct a new object that implements
202+ IReclaimGitRepositorySpaceJob.
203+
204+ :param repository_name: The unique name of the repository to remove
205+ from storage.
206+ :param repository_path: The storage path of the repository to remove
207+ from storage.
208+ """
209
210=== modified file 'lib/lp/code/interfaces/gitrepository.py'
211--- lib/lp/code/interfaces/gitrepository.py 2015-05-14 13:57:51 +0000
212+++ lib/lp/code/interfaces/gitrepository.py 2015-05-19 11:33:34 +0000
213@@ -449,6 +449,18 @@
214 and their subscriptions.
215 """
216
217+ # XXX cjwatson 2015-04-16: These names are too awful to set in stone by
218+ # exporting them on the webservice; find better names before exporting.
219+ landing_targets = Attribute(
220+ "A collection of the merge proposals where this repository is the "
221+ "source.")
222+ landing_candidates = Attribute(
223+ "A collection of the merge proposals where this repository is the "
224+ "target.")
225+ dependent_landings = Attribute(
226+ "A collection of the merge proposals that are dependent on this "
227+ "repository.")
228+
229 def isRepositoryMergeable(other):
230 """Is the other repository mergeable into this one (or vice versa)?"""
231
232@@ -532,10 +544,35 @@
233 def setTarget(target, user):
234 """Set the target of the repository."""
235
236+ @export_read_operation()
237+ @operation_for_version("devel")
238+ def canBeDeleted():
239+ """Can this repository be deleted in its current state?
240+
241+ A repository is considered deletable if it is not linked to any
242+ merge proposals.
243+ """
244+
245+ def getDeletionRequirements():
246+ """Determine what is required to delete this branch.
247+
248+ :return: a dict of {object: (operation, reason)}, where object is the
249+ object that must be deleted or altered, operation is either
250+ "delete" or "alter", and reason is a string explaining why the
251+ object needs to be touched.
252+ """
253+
254+ @call_with(break_references=True)
255 @export_destructor_operation()
256 @operation_for_version("devel")
257- def destroySelf():
258- """Delete the specified repository."""
259+ def destroySelf(break_references=False):
260+ """Delete the specified repository.
261+
262+ :param break_references: If supplied, break any references to this
263+ repository by deleting items with mandatory references and
264+ NULLing other references.
265+ :raise: CannotDeleteGitRepository if the repository cannot be deleted.
266+ """
267
268
269 class IGitRepository(IGitRepositoryView, IGitRepositoryModerateAttributes,
270
271=== modified file 'lib/lp/code/model/branch.py'
272--- lib/lp/code/model/branch.py 2015-04-30 21:49:59 +0000
273+++ lib/lp/code/model/branch.py 2015-05-19 11:33:34 +0000
274@@ -1250,10 +1250,6 @@
275 datetime.now(pytz.timezone('UTC'))
276 + increment * 2 ** (self.mirror_failures - 1))
277
278- def destroySelfBreakReferences(self):
279- """See `IBranch`."""
280- return self.destroySelf(break_references=True)
281-
282 def _deleteBranchSubscriptions(self):
283 """Delete subscriptions for this branch prior to deleting branch."""
284 subscriptions = Store.of(self).find(
285
286=== modified file 'lib/lp/code/model/gitjob.py'
287--- lib/lp/code/model/gitjob.py 2015-05-14 09:46:25 +0000
288+++ lib/lp/code/model/gitjob.py 2015-05-19 11:33:34 +0000
289@@ -7,6 +7,7 @@
290 'GitJob',
291 'GitJobType',
292 'GitRefScanJob',
293+ 'ReclaimGitRepositorySpaceJob',
294 ]
295
296 from lazr.delegates import delegates
297@@ -19,6 +20,7 @@
298 Int,
299 JSON,
300 Reference,
301+ SQL,
302 Store,
303 )
304 from zope.interface import (
305@@ -32,6 +34,8 @@
306 IGitJob,
307 IGitRefScanJob,
308 IGitRefScanJobSource,
309+ IReclaimGitRepositorySpaceJob,
310+ IReclaimGitRepositorySpaceJobSource,
311 )
312 from lp.services.config import config
313 from lp.services.database.enumcol import EnumCol
314@@ -63,6 +67,13 @@
315 This job scans a repository for its current list of references.
316 """)
317
318+ RECLAIM_REPOSITORY_SPACE = DBItem(1, """
319+ Reclaim repository space
320+
321+ This job removes a repository that has been deleted from the
322+ database from storage.
323+ """)
324+
325
326 class GitJob(StormBase):
327 """See `IGitJob`."""
328@@ -74,7 +85,7 @@
329 job_id = Int(name='job', primary=True, allow_none=False)
330 job = Reference(job_id, 'Job.id')
331
332- repository_id = Int(name='repository', allow_none=False)
333+ repository_id = Int(name='repository', allow_none=True)
334 repository = Reference(repository_id, 'GitRepository.id')
335
336 job_type = EnumCol(enum=GitJobType, notNull=True)
337@@ -97,7 +108,8 @@
338 self.repository = repository
339 self.job_type = job_type
340 self.metadata = metadata
341- self.metadata["repository_name"] = repository.unique_name
342+ if repository is not None:
343+ self.metadata["repository_name"] = repository.unique_name
344
345 def makeDerived(self):
346 return GitJobDerived.makeSubclass(self)
347@@ -205,3 +217,43 @@
348 log.info(
349 "Skipping repository %s because it has been deleted." %
350 self._cached_repository_name)
351+
352+
353+class ReclaimGitRepositorySpaceJob(GitJobDerived):
354+ """A Job that deletes a repository from storage after it has been
355+ deleted from the database."""
356+
357+ implements(IReclaimGitRepositorySpaceJob)
358+
359+ classProvides(IReclaimGitRepositorySpaceJobSource)
360+ class_job_type = GitJobType.RECLAIM_REPOSITORY_SPACE
361+
362+ config = config.IReclaimGitRepositorySpaceJobSource
363+
364+ @classmethod
365+ def create(cls, repository_name, repository_path):
366+ "See `IReclaimGitRepositorySpaceJobSource`."""
367+ metadata = {
368+ "repository_name": repository_name,
369+ "repository_path": repository_path,
370+ }
371+ # The GitJob has a repository of None, as there is no repository
372+ # left in the database to refer to.
373+ start = SQL("CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + '7 days'")
374+ git_job = GitJob(
375+ None, cls.class_job_type, metadata, scheduled_start=start)
376+ job = cls(git_job)
377+ job.celeryRunOnCommit()
378+ return job
379+
380+ def __init__(self, git_job):
381+ super(ReclaimGitRepositorySpaceJob, self).__init__(git_job)
382+ self._hosting_client = GitHostingClient(
383+ config.codehosting.internal_git_api_endpoint)
384+
385+ @property
386+ def repository_path(self):
387+ return self.metadata["repository_path"]
388+
389+ def run(self):
390+ self._hosting_client.delete(self.repository_path, logger=log)
391
392=== modified file 'lib/lp/code/model/gitrepository.py'
393--- lib/lp/code/model/gitrepository.py 2015-05-14 13:57:51 +0000
394+++ lib/lp/code/model/gitrepository.py 2015-05-19 11:33:34 +0000
395@@ -20,6 +20,7 @@
396 Coalesce,
397 Insert,
398 Join,
399+ Not,
400 Or,
401 Select,
402 SQL,
403@@ -39,8 +40,12 @@
404 from zope.component import getUtility
405 from zope.interface import implements
406 from zope.security.interfaces import Unauthorized
407-from zope.security.proxy import removeSecurityProxy
408+from zope.security.proxy import (
409+ ProxyFactory,
410+ removeSecurityProxy,
411+ )
412
413+from lp import _ as msg
414 from lp.app.enums import (
415 InformationType,
416 PRIVATE_INFORMATION_TYPES,
417@@ -58,9 +63,13 @@
418 from lp.app.interfaces.services import IService
419 from lp.code.enums import GitObjectType
420 from lp.code.errors import (
421+ CannotDeleteGitRepository,
422 GitDefaultConflict,
423 GitTargetError,
424 )
425+from lp.code.interfaces.branchmergeproposal import (
426+ BRANCH_MERGE_PROPOSAL_FINAL_STATES,
427+ )
428 from lp.code.interfaces.gitcollection import (
429 IAllGitRepositories,
430 IGitCollection,
431@@ -78,6 +87,7 @@
432 )
433 from lp.code.interfaces.revision import IRevisionSet
434 from lp.code.mail.branch import send_git_repository_modified_notifications
435+from lp.code.model.branchmergeproposal import BranchMergeProposal
436 from lp.code.model.gitref import GitRef
437 from lp.code.model.gitsubscription import GitSubscription
438 from lp.registry.enums import PersonVisibility
439@@ -756,6 +766,31 @@
440 recipients.add(subscription.person, subscription, rationale)
441 return recipients
442
443+ @property
444+ def landing_targets(self):
445+ """See `IGitRepository`."""
446+ return Store.of(self).find(
447+ BranchMergeProposal,
448+ BranchMergeProposal.source_git_repository == self)
449+
450+ @property
451+ def landing_candidates(self):
452+ """See `IGitRepository`."""
453+ return Store.of(self).find(
454+ BranchMergeProposal,
455+ BranchMergeProposal.target_git_repository == self,
456+ Not(BranchMergeProposal.queue_status.is_in(
457+ BRANCH_MERGE_PROPOSAL_FINAL_STATES)))
458+
459+ @property
460+ def dependent_landings(self):
461+ """See `IGitRepository`."""
462+ return Store.of(self).find(
463+ BranchMergeProposal,
464+ BranchMergeProposal.prerequisite_git_repository,
465+ Not(BranchMergeProposal.queue_status.is_in(
466+ BRANCH_MERGE_PROPOSAL_FINAL_STATES)))
467+
468 def isRepositoryMergeable(self, other):
469 """See `IGitRepository`."""
470 return self.namespace.areRepositoriesMergeable(other.namespace)
471@@ -775,8 +810,164 @@
472 Job._status.is_in([JobStatus.WAITING, JobStatus.RUNNING]))
473 return not jobs.is_empty()
474
475- def destroySelf(self):
476- raise NotImplementedError
477+ def canBeDeleted(self):
478+ """See `IGitRepository`."""
479+ # Can't delete if the repository is associated with anything.
480+ return len(self.getDeletionRequirements()) == 0
481+
482+ def _getDeletionRequirements(self):
483+ """Determine what operations must be performed to delete this branch.
484+
485+ Two dictionaries are returned, one for items that must be deleted,
486+ one for items that must be altered. The item in question is the
487+ key, and the value is a user-facing string explaining why the item
488+ is affected.
489+
490+ As well as the dictionaries, this method returns two list of callables
491+ that may be called to perform the alterations and deletions needed.
492+ """
493+ alteration_operations = []
494+ deletion_operations = []
495+ # Merge proposals require their source and target repositories to
496+ # exist.
497+ for merge_proposal in self.landing_targets:
498+ deletion_operations.append(
499+ DeletionCallable(
500+ merge_proposal,
501+ msg("This repository is the source repository of this "
502+ "merge proposal."),
503+ merge_proposal.deleteProposal))
504+ # Cannot use self.landing_candidates, because it ignores merged
505+ # merge proposals.
506+ for merge_proposal in BranchMergeProposal.selectBy(
507+ target_git_repository=self):
508+ deletion_operations.append(
509+ DeletionCallable(
510+ merge_proposal,
511+ msg("This repository is the target repository of this "
512+ "merge proposal."),
513+ merge_proposal.deleteProposal))
514+ for merge_proposal in BranchMergeProposal.selectBy(
515+ prerequisite_git_repository=self):
516+ alteration_operations.append(
517+ ClearPrerequisiteRepository(merge_proposal))
518+
519+ return (alteration_operations, deletion_operations)
520+
521+ def getDeletionRequirements(self):
522+ """See `IGitRepository`."""
523+ alteration_operations, deletion_operations = (
524+ self._getDeletionRequirements())
525+ result = {
526+ operation.affected_object: ("alter", operation.rationale)
527+ for operation in alteration_operations}
528+ # Deletion entries should overwrite alteration entries.
529+ result.update({
530+ operation.affected_object: ("delete", operation.rationale)
531+ for operation in deletion_operations})
532+ return result
533+
534+ def _breakReferences(self):
535+ """Break all external references to this repository.
536+
537+ NULLable references will be NULLed. References which are not NULLable
538+ will cause the item holding the reference to be deleted.
539+
540+ This function is guaranteed to perform the operations predicted by
541+ getDeletionRequirements, because it uses the same backing function.
542+ """
543+ alteration_operations, deletion_operations = (
544+ self._getDeletionRequirements())
545+ for operation in alteration_operations:
546+ operation()
547+ for operation in deletion_operations:
548+ operation()
549+ Store.of(self).flush()
550+
551+ def _deleteRepositorySubscriptions(self):
552+ """Delete subscriptions for this repository prior to deleting it."""
553+ subscriptions = Store.of(self).find(
554+ GitSubscription, GitSubscription.repository == self)
555+ subscriptions.remove()
556+
557+ def _deleteJobs(self):
558+ """Delete jobs for this repository prior to deleting it.
559+
560+ This deletion includes `GitJob`s associated with the branch.
561+ """
562+ # Circular import.
563+ from lp.code.model.gitjob import GitJob
564+
565+ # Remove GitJobs.
566+ affected_jobs = Select(
567+ [GitJob.job_id],
568+ And(GitJob.job == Job.id, GitJob.repository == self))
569+ Store.of(self).find(Job, Job.id.is_in(affected_jobs)).remove()
570+
571+ def destroySelf(self, break_references=False):
572+ """See `IGitRepository`."""
573+ # Circular import.
574+ from lp.code.interfaces.gitjob import (
575+ IReclaimGitRepositorySpaceJobSource,
576+ )
577+
578+ if break_references:
579+ self._breakReferences()
580+ if not self.canBeDeleted():
581+ raise CannotDeleteGitRepository(
582+ "Cannot delete Git repository: %s" % self.unique_name)
583+
584+ self.refs.remove()
585+ self._deleteRepositorySubscriptions()
586+ self._deleteJobs()
587+
588+ # Now destroy the repository.
589+ repository_name = self.unique_name
590+ repository_path = self.getInternalPath()
591+ Store.of(self).remove(self)
592+ # And now create a job to remove the repository from storage when
593+ # it's done.
594+ getUtility(IReclaimGitRepositorySpaceJobSource).create(
595+ repository_name, repository_path)
596+
597+
598+class DeletionOperation:
599+ """Represent an operation to perform as part of branch deletion."""
600+
601+ def __init__(self, affected_object, rationale):
602+ self.affected_object = ProxyFactory(affected_object)
603+ self.rationale = rationale
604+
605+ def __call__(self):
606+ """Perform the deletion operation."""
607+ raise NotImplementedError(DeletionOperation.__call__)
608+
609+
610+class DeletionCallable(DeletionOperation):
611+ """Deletion operation that invokes a callable."""
612+
613+ def __init__(self, affected_object, rationale, func):
614+ super(DeletionCallable, self).__init__(affected_object, rationale)
615+ self.func = func
616+
617+ def __call__(self):
618+ self.func()
619+
620+
621+class ClearPrerequisiteRepository(DeletionOperation):
622+ """Delete operation that clears a merge proposal's prerequisite
623+ repository."""
624+
625+ def __init__(self, merge_proposal):
626+ DeletionOperation.__init__(
627+ self, merge_proposal,
628+ msg("This repository is the prerequisite repository of this merge "
629+ "proposal."))
630+
631+ def __call__(self):
632+ self.affected_object.prerequisite_git_repository = None
633+ self.affected_object.prerequisite_git_path = None
634+ self.affected_object.prerequisite_git_commit_sha1 = None
635
636
637 class GitRepositorySet:
638
639=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
640--- lib/lp/code/model/tests/test_gitjob.py 2015-05-14 13:57:51 +0000
641+++ lib/lp/code/model/tests/test_gitjob.py 2015-05-19 11:33:34 +0000
642@@ -16,18 +16,22 @@
643 MatchesSetwise,
644 MatchesStructure,
645 )
646+from zope.security.proxy import removeSecurityProxy
647
648 from lp.code.enums import GitObjectType
649 from lp.code.interfaces.gitjob import (
650 IGitJob,
651 IGitRefScanJob,
652+ IReclaimGitRepositorySpaceJob,
653 )
654 from lp.code.model.gitjob import (
655 GitJob,
656 GitJobDerived,
657 GitJobType,
658 GitRefScanJob,
659+ ReclaimGitRepositorySpaceJob,
660 )
661+from lp.services.database.constants import UTC_NOW
662 from lp.testing import (
663 TestCaseWithFactory,
664 time_counter,
665@@ -65,7 +69,10 @@
666 self.assertIsNone(derived.getOopsMailController("x"))
667
668
669-class TestGitRefScanJobMixin:
670+class TestGitRefScanJob(TestCaseWithFactory):
671+ """Tests for `GitRefScanJob`."""
672+
673+ layer = LaunchpadZopelessLayer
674
675 @staticmethod
676 def makeFakeRefs(paths):
677@@ -107,12 +114,6 @@
678 for path in paths]
679 self.assertThat(refs, MatchesSetwise(*matchers))
680
681-
682-class TestGitRefScanJob(TestGitRefScanJobMixin, TestCaseWithFactory):
683- """Tests for `GitRefScanJob`."""
684-
685- layer = LaunchpadZopelessLayer
686-
687 def test_provides_interface(self):
688 # `GitRefScanJob` objects provide `IGitRefScanJob`.
689 repository = self.factory.makeGitRepository()
690@@ -156,5 +157,64 @@
691 self.assertEqual([], list(repository.refs))
692
693
694-# XXX cjwatson 2015-03-12: We should test that the job works via Celery too,
695+class TestReclaimGitRepositorySpaceJob(TestCaseWithFactory):
696+ """Tests for `ReclaimGitRepositorySpaceJob`."""
697+
698+ layer = LaunchpadZopelessLayer
699+
700+ def test_provides_interface(self):
701+ # `ReclaimGitRepositorySpaceJob` objects provide
702+ # `IReclaimGitRepositorySpaceJob`.
703+ self.assertProvides(
704+ ReclaimGitRepositorySpaceJob.create("/~owner/+git/gone", "1"),
705+ IReclaimGitRepositorySpaceJob)
706+
707+ def test___repr__(self):
708+ # `ReclaimGitRepositorySpaceJob` objects have an informative
709+ # __repr__.
710+ name = "/~owner/+git/gone"
711+ job = ReclaimGitRepositorySpaceJob.create(name, "1")
712+ self.assertEqual(
713+ "<ReclaimGitRepositorySpaceJob for %s>" % name, repr(job))
714+
715+ def test_scheduled_in_future(self):
716+ # A freshly created ReclaimGitRepositorySpaceJob is scheduled to run
717+ # in a week's time.
718+ job = ReclaimGitRepositorySpaceJob.create("/~owner/+git/gone", "1")
719+ self.assertEqual(
720+ timedelta(days=7), job.job.scheduled_start - job.job.date_created)
721+
722+ def test_stores_name_and_path(self):
723+ # An instance of ReclaimGitRepositorySpaceJob stores the name and
724+ # path of the repository that has been deleted.
725+ name = "/~owner/+git/gone"
726+ path = "1"
727+ job = ReclaimGitRepositorySpaceJob.create(name, path)
728+ self.assertEqual(name, job._cached_repository_name)
729+ self.assertEqual(path, job.repository_path)
730+
731+ def makeJobReady(self, job):
732+ """Force `job` to be scheduled to run now.
733+
734+ New `ReclaimGitRepositorySpaceJob`s are scheduled to run a week
735+ after creation, so to be able to test running the job we have to
736+ force them to be scheduled now.
737+ """
738+ removeSecurityProxy(job).job.scheduled_start = UTC_NOW
739+
740+ def test_run(self):
741+ # Running a job to reclaim space sends a request to the hosting
742+ # service.
743+ name = "/~owner/+git/gone"
744+ path = "1"
745+ job = ReclaimGitRepositorySpaceJob.create(name, path)
746+ self.makeJobReady(job)
747+ [job] = list(ReclaimGitRepositorySpaceJob.iterReady())
748+ with dbuser("reclaim-branch-space"):
749+ job._hosting_client.delete = FakeMethod()
750+ job.run()
751+ self.assertEqual([(path,)], job._hosting_client.delete.extract_args())
752+
753+
754+# XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too,
755 # but that isn't feasible until we have a proper turnip fixture.
756
757=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
758--- lib/lp/code/model/tests/test_gitrepository.py 2015-05-14 13:57:51 +0000
759+++ lib/lp/code/model/tests/test_gitrepository.py 2015-05-19 11:33:34 +0000
760@@ -14,6 +14,8 @@
761 from bzrlib import urlutils
762 from lazr.lifecycle.event import ObjectModifiedEvent
763 from lazr.lifecycle.snapshot import Snapshot
764+from sqlobject import SQLObjectNotFound
765+from storm.store import Store
766 import transaction
767 import pytz
768 from testtools.matchers import (
769@@ -27,6 +29,7 @@
770 from zope.security.interfaces import Unauthorized
771 from zope.security.proxy import removeSecurityProxy
772
773+from lp import _
774 from lp.app.enums import (
775 InformationType,
776 PRIVATE_INFORMATION_TYPES,
777@@ -40,6 +43,7 @@
778 GitObjectType,
779 )
780 from lp.code.errors import (
781+ CannotDeleteGitRepository,
782 GitRepositoryCreatorNotMemberOfOwnerTeam,
783 GitRepositoryCreatorNotOwner,
784 GitRepositoryExists,
785@@ -47,6 +51,7 @@
786 )
787 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
788 from lp.code.interfaces.gitjob import IGitRefScanJobSource
789+from lp.code.interfaces.gitlookup import IGitLookup
790 from lp.code.interfaces.gitnamespace import (
791 IGitNamespacePolicy,
792 IGitNamespaceSet,
793@@ -56,7 +61,19 @@
794 IGitRepositorySet,
795 )
796 from lp.code.interfaces.revision import IRevisionSet
797-from lp.code.model.gitrepository import GitRepository
798+from lp.code.model.branchmergeproposal import BranchMergeProposal
799+from lp.code.model.codereviewcomment import CodeReviewComment
800+from lp.code.model.gitjob import (
801+ GitJob,
802+ GitJobType,
803+ ReclaimGitRepositorySpaceJob,
804+ )
805+from lp.code.model.gitrepository import (
806+ ClearPrerequisiteRepository,
807+ DeletionCallable,
808+ DeletionOperation,
809+ GitRepository,
810+ )
811 from lp.code.xmlrpc.git import GitAPI
812 from lp.registry.enums import (
813 BranchSharingPolicy,
814@@ -84,6 +101,7 @@
815 ANONYMOUS,
816 api_url,
817 celebrity_logged_in,
818+ login_person,
819 person_logged_in,
820 TestCaseWithFactory,
821 verifyObject,
822@@ -93,6 +111,7 @@
823 from lp.testing.layers import (
824 DatabaseFunctionalLayer,
825 LaunchpadFunctionalLayer,
826+ LaunchpadZopelessLayer,
827 )
828 from lp.testing.pages import webservice_for_person
829
830@@ -284,6 +303,305 @@
831 repository.getRepositoryIdentities())
832
833
834+class TestGitRepositoryDeletion(TestCaseWithFactory):
835+ """Test the different cases that make a repository deletable or not."""
836+
837+ layer = LaunchpadZopelessLayer
838+
839+ def setUp(self):
840+ super(TestGitRepositoryDeletion, self).setUp()
841+ self.user = self.factory.makePerson()
842+ self.project = self.factory.makeProduct(owner=self.user)
843+ self.repository = self.factory.makeGitRepository(
844+ name=u"to-delete", owner=self.user, target=self.project)
845+ [self.ref] = self.factory.makeGitRefs(repository=self.repository)
846+ # The owner of the repository is subscribed to the repository when
847+ # it is created. The tests here assume no initial connections, so
848+ # unsubscribe the repository owner here.
849+ self.repository.unsubscribe(
850+ self.repository.owner, self.repository.owner)
851+ # Make sure that the tests all flush the database changes.
852+ self.addCleanup(Store.of(self.repository).flush)
853+ login_person(self.user)
854+
855+ def test_deletable(self):
856+ # A newly created repository can be deleted without any problems.
857+ self.assertTrue(
858+ self.repository.canBeDeleted(),
859+ "A newly created repository should be able to be deleted.")
860+ repository_id = self.repository.id
861+ self.repository.destroySelf()
862+ self.assertIsNone(
863+ getUtility(IGitLookup).get(repository_id),
864+ "The repository has not been deleted.")
865+
866+ def test_subscription_does_not_disable_deletion(self):
867+ # A repository that has a subscription can be deleted.
868+ self.repository.subscribe(
869+ self.user, BranchSubscriptionNotificationLevel.NOEMAIL, None,
870+ CodeReviewNotificationLevel.NOEMAIL, self.user)
871+ self.assertTrue(self.repository.canBeDeleted())
872+
873+ def test_landing_target_disables_deletion(self):
874+ # A repository with a landing target cannot be deleted.
875+ [merge_target] = self.factory.makeGitRefs(
876+ name=u"landing-target", owner=self.user, target=self.project)
877+ self.ref.addLandingTarget(self.user, merge_target)
878+ self.assertFalse(
879+ self.repository.canBeDeleted(),
880+ "A repository with a landing target is not deletable.")
881+ self.assertRaises(
882+ CannotDeleteGitRepository, self.repository.destroySelf)
883+
884+ def test_landing_candidate_disables_deletion(self):
885+ # A repository with a landing candidate cannot be deleted.
886+ [merge_source] = self.factory.makeGitRefs(
887+ name=u"landing-candidate", owner=self.user, target=self.project)
888+ merge_source.addLandingTarget(self.user, self.ref)
889+ self.assertFalse(
890+ self.repository.canBeDeleted(),
891+ "A repository with a landing candidate is not deletable.")
892+ self.assertRaises(
893+ CannotDeleteGitRepository, self.repository.destroySelf)
894+
895+ def test_prerequisite_repository_disables_deletion(self):
896+ # A repository that is a prerequisite repository cannot be deleted.
897+ [merge_source] = self.factory.makeGitRefs(
898+ name=u"landing-candidate", owner=self.user, target=self.project)
899+ [merge_target] = self.factory.makeGitRefs(
900+ name=u"landing-target", owner=self.user, target=self.project)
901+ merge_source.addLandingTarget(self.user, merge_target, self.ref)
902+ self.assertFalse(
903+ self.repository.canBeDeleted(),
904+ "A repository with a prerequisite target is not deletable.")
905+ self.assertRaises(
906+ CannotDeleteGitRepository, self.repository.destroySelf)
907+
908+ def test_related_GitJobs_deleted(self):
909+ # A repository with an associated job will delete those jobs.
910+ repository = self.factory.makeGitRepository()
911+ GitAPI(None, None).notify(repository.getInternalPath())
912+ store = Store.of(repository)
913+ repository.destroySelf()
914+ # Need to commit the transaction to fire off the constraint checks.
915+ transaction.commit()
916+ jobs = store.find(GitJob, GitJob.job_type == GitJobType.REF_SCAN)
917+ self.assertEqual([], list(jobs))
918+
919+ def test_creates_job_to_reclaim_space(self):
920+ # When a repository is deleted from the database, a job is created
921+ # to remove the repository from disk as well.
922+ repository = self.factory.makeGitRepository()
923+ repository_path = repository.getInternalPath()
924+ store = Store.of(repository)
925+ repository.destroySelf()
926+ jobs = store.find(
927+ GitJob,
928+ GitJob.job_type == GitJobType.RECLAIM_REPOSITORY_SPACE)
929+ self.assertEqual(
930+ [repository_path],
931+ [ReclaimGitRepositorySpaceJob(job).repository_path
932+ for job in jobs])
933+
934+ def test_destroySelf_with_inline_comments_draft(self):
935+ # Draft inline comments related to a deleted repository (source or
936+ # target MP repository) also get removed.
937+ merge_proposal = self.factory.makeBranchMergeProposalForGit(
938+ registrant=self.user, target_ref=self.ref)
939+ preview_diff = self.factory.makePreviewDiff(
940+ merge_proposal=merge_proposal)
941+ transaction.commit()
942+ merge_proposal.saveDraftInlineComment(
943+ previewdiff_id=preview_diff.id,
944+ person=self.user,
945+ comments={"1": "Should vanish."})
946+ self.repository.destroySelf(break_references=True)
947+
948+ def test_destroySelf_with_inline_comments_published(self):
949+ # Published inline comments related to a deleted repository (source
950+ # or target MP repository) also get removed.
951+ merge_proposal = self.factory.makeBranchMergeProposalForGit(
952+ registrant=self.user, target_ref=self.ref)
953+ preview_diff = self.factory.makePreviewDiff(
954+ merge_proposal=merge_proposal)
955+ transaction.commit()
956+ merge_proposal.createComment(
957+ owner=self.user,
958+ subject="Delete me!",
959+ previewdiff_id=preview_diff.id,
960+ inline_comments={"1": "Must disappear."},
961+ )
962+ self.repository.destroySelf(break_references=True)
963+
964+
965+class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
966+ """Test determination and application of repository deletion
967+ consequences."""
968+
969+ layer = LaunchpadZopelessLayer
970+
971+ def setUp(self):
972+ super(TestGitRepositoryDeletionConsequences, self).setUp(
973+ user="test@canonical.com")
974+ self.repository = self.factory.makeGitRepository()
975+ [self.ref] = self.factory.makeGitRefs(repository=self.repository)
976+ # The owner of the repository is subscribed to the repository when
977+ # it is created. The tests here assume no initial connections, so
978+ # unsubscribe the repository owner here.
979+ self.repository.unsubscribe(
980+ self.repository.owner, self.repository.owner)
981+
982+ def test_plain_repository(self):
983+ # A fresh repository has no deletion requirements.
984+ self.assertEqual({}, self.repository.getDeletionRequirements())
985+
986+ def makeMergeProposals(self):
987+ # Produce a merge proposal for testing purposes.
988+ [merge_target] = self.factory.makeGitRefs(target=self.ref.target)
989+ [merge_prerequisite] = self.factory.makeGitRefs(target=self.ref.target)
990+ # Remove the implicit subscriptions.
991+ merge_target.repository.unsubscribe(
992+ merge_target.owner, merge_target.owner)
993+ merge_prerequisite.repository.unsubscribe(
994+ merge_prerequisite.owner, merge_prerequisite.owner)
995+ merge_proposal1 = self.ref.addLandingTarget(
996+ self.ref.owner, merge_target, merge_prerequisite)
997+ # Disable this merge proposal, to allow creating a new identical one.
998+ lp_admins = getUtility(ILaunchpadCelebrities).admin
999+ merge_proposal1.rejectBranch(lp_admins, "null:")
1000+ merge_proposal2 = self.ref.addLandingTarget(
1001+ self.ref.owner, merge_target, merge_prerequisite)
1002+ return merge_proposal1, merge_proposal2
1003+
1004+ def test_repository_with_merge_proposal(self):
1005+ # Ensure that deletion requirements with a merge proposal are right.
1006+ #
1007+ # Each repository related to the merge proposal is tested to ensure
1008+ # it produces a unique, correct result.
1009+ merge_proposal1, merge_proposal2 = self.makeMergeProposals()
1010+ self.assertEqual(
1011+ {
1012+ merge_proposal1:
1013+ ("delete",
1014+ _("This repository is the source repository of this merge "
1015+ "proposal.")),
1016+ merge_proposal2:
1017+ ("delete",
1018+ _("This repository is the source repository of this merge "
1019+ "proposal.")),
1020+ },
1021+ self.repository.getDeletionRequirements())
1022+ target = merge_proposal1.target_git_repository
1023+ self.assertEqual(
1024+ {
1025+ merge_proposal1:
1026+ ("delete",
1027+ _("This repository is the target repository of this merge "
1028+ "proposal.")),
1029+ merge_proposal2:
1030+ ("delete",
1031+ _("This repository is the target repository of this merge "
1032+ "proposal.")),
1033+ },
1034+ target.getDeletionRequirements())
1035+ prerequisite = merge_proposal1.prerequisite_git_repository
1036+ self.assertEqual(
1037+ {
1038+ merge_proposal1:
1039+ ("alter",
1040+ _("This repository is the prerequisite repository of this "
1041+ "merge proposal.")),
1042+ merge_proposal2:
1043+ ("alter",
1044+ _("This repository is the prerequisite repository of this "
1045+ "merge proposal.")),
1046+ },
1047+ prerequisite.getDeletionRequirements())
1048+
1049+ def test_delete_merge_proposal_source(self):
1050+ # Merge proposal source repositories can be deleted with
1051+ # break_references.
1052+ merge_proposal1, merge_proposal2 = self.makeMergeProposals()
1053+ merge_proposal1_id = merge_proposal1.id
1054+ BranchMergeProposal.get(merge_proposal1_id)
1055+ self.repository.destroySelf(break_references=True)
1056+ self.assertRaises(
1057+ SQLObjectNotFound, BranchMergeProposal.get, merge_proposal1_id)
1058+
1059+ def test_delete_merge_proposal_target(self):
1060+ # Merge proposal target repositories can be deleted with
1061+ # break_references.
1062+ merge_proposal1, merge_proposal2 = self.makeMergeProposals()
1063+ merge_proposal1_id = merge_proposal1.id
1064+ BranchMergeProposal.get(merge_proposal1_id)
1065+ merge_proposal1.target_git_repository.destroySelf(
1066+ break_references=True)
1067+ self.assertRaises(SQLObjectNotFound,
1068+ BranchMergeProposal.get, merge_proposal1_id)
1069+
1070+ def test_delete_merge_proposal_prerequisite(self):
1071+ # Merge proposal prerequisite repositories can be deleted with
1072+ # break_references.
1073+ merge_proposal1, merge_proposal2 = self.makeMergeProposals()
1074+ merge_proposal1.prerequisite_git_repository.destroySelf(
1075+ break_references=True)
1076+ self.assertIsNone(merge_proposal1.prerequisite_git_repository)
1077+
1078+ def test_delete_source_CodeReviewComment(self):
1079+ # Deletion of source repositories that have CodeReviewComments works.
1080+ comment = self.factory.makeCodeReviewComment(git=True)
1081+ comment_id = comment.id
1082+ repository = comment.branch_merge_proposal.source_git_repository
1083+ repository.destroySelf(break_references=True)
1084+ self.assertRaises(
1085+ SQLObjectNotFound, CodeReviewComment.get, comment_id)
1086+
1087+ def test_delete_target_CodeReviewComment(self):
1088+ # Deletion of target repositories that have CodeReviewComments works.
1089+ comment = self.factory.makeCodeReviewComment(git=True)
1090+ comment_id = comment.id
1091+ repository = comment.branch_merge_proposal.target_git_repository
1092+ repository.destroySelf(break_references=True)
1093+ self.assertRaises(
1094+ SQLObjectNotFound, CodeReviewComment.get, comment_id)
1095+
1096+ def test_sourceBranchWithCodeReviewVoteReference(self):
1097+ # break_references handles CodeReviewVoteReference source repository.
1098+ merge_proposal = self.factory.makeBranchMergeProposalForGit()
1099+ merge_proposal.nominateReviewer(
1100+ self.factory.makePerson(), self.factory.makePerson())
1101+ merge_proposal.source_git_repository.destroySelf(break_references=True)
1102+
1103+ def test_targetBranchWithCodeReviewVoteReference(self):
1104+ # break_references handles CodeReviewVoteReference target repository.
1105+ merge_proposal = self.factory.makeBranchMergeProposalForGit()
1106+ merge_proposal.nominateReviewer(
1107+ self.factory.makePerson(), self.factory.makePerson())
1108+ merge_proposal.target_git_repository.destroySelf(break_references=True)
1109+
1110+ def test_ClearPrerequisiteRepository(self):
1111+ # ClearPrerequisiteRepository.__call__ must clear the prerequisite
1112+ # repository.
1113+ merge_proposal = removeSecurityProxy(self.makeMergeProposals()[0])
1114+ with person_logged_in(
1115+ merge_proposal.prerequisite_git_repository.owner):
1116+ ClearPrerequisiteRepository(merge_proposal)()
1117+ self.assertIsNone(merge_proposal.prerequisite_git_repository)
1118+
1119+ def test_DeletionOperation(self):
1120+ # DeletionOperation.__call__ is not implemented.
1121+ self.assertRaises(NotImplementedError, DeletionOperation("a", "b"))
1122+
1123+ def test_DeletionCallable(self):
1124+ # DeletionCallable must invoke the callable.
1125+ merge_proposal = self.factory.makeBranchMergeProposalForGit()
1126+ merge_proposal_id = merge_proposal.id
1127+ DeletionCallable(
1128+ merge_proposal, "blah", merge_proposal.deleteProposal)()
1129+ self.assertRaises(
1130+ SQLObjectNotFound, BranchMergeProposal.get, merge_proposal_id)
1131+
1132+
1133 class TestGitRepositoryModifications(TestCaseWithFactory):
1134 """Tests for Git repository modification notifications."""
1135
1136
1137=== modified file 'lib/lp/services/config/schema-lazr.conf'
1138--- lib/lp/services/config/schema-lazr.conf 2015-04-22 16:00:09 +0000
1139+++ lib/lp/services/config/schema-lazr.conf 2015-05-19 11:33:34 +0000
1140@@ -1818,6 +1818,10 @@
1141 module: lp.code.interfaces.branchjob
1142 dbuser: reclaim-branch-space
1143
1144+[IReclaimGitRepositorySpaceJobSource]
1145+module: lp.code.interfaces.gitjob
1146+dbuser: reclaim-branch-space
1147+
1148 [IRemoveArtifactSubscriptionsJobSource]
1149 module: lp.registry.interfaces.sharingjob
1150 dbuser: sharing-jobs
1151
1152=== modified file 'lib/lp/testing/factory.py'
1153--- lib/lp/testing/factory.py 2015-05-19 00:41:13 +0000
1154+++ lib/lp/testing/factory.py 2015-05-19 11:33:34 +0000
1155@@ -1567,10 +1567,13 @@
1156 Diff.fromFile(StringIO(diff_text), len(diff_text)))
1157
1158 def makePreviewDiff(self, conflicts=u'', merge_proposal=None,
1159- date_created=None, size='small'):
1160+ date_created=None, size='small', git=False):
1161 diff = self.makeDiff(size)
1162 if merge_proposal is None:
1163- merge_proposal = self.makeBranchMergeProposal()
1164+ if git:
1165+ merge_proposal = self.makeBranchMergeProposalForGit()
1166+ else:
1167+ merge_proposal = self.makeBranchMergeProposal()
1168 preview_diff = PreviewDiff()
1169 preview_diff.branch_merge_proposal = merge_proposal
1170 preview_diff.conflicts = conflicts
1171@@ -2438,7 +2441,8 @@
1172
1173 def makeCodeReviewComment(self, sender=None, subject=None, body=None,
1174 vote=None, vote_tag=None, parent=None,
1175- merge_proposal=None, date_created=DEFAULT):
1176+ merge_proposal=None, date_created=DEFAULT,
1177+ git=False):
1178 if sender is None:
1179 sender = self.makePerson()
1180 if subject is None:
1181@@ -2448,6 +2452,9 @@
1182 if merge_proposal is None:
1183 if parent:
1184 merge_proposal = parent.branch_merge_proposal
1185+ elif git:
1186+ merge_proposal = self.makeBranchMergeProposalForGit(
1187+ registrant=sender)
1188 else:
1189 merge_proposal = self.makeBranchMergeProposal(
1190 registrant=sender)
1191@@ -2456,8 +2463,11 @@
1192 sender, subject, body, vote, vote_tag, parent,
1193 _date_created=date_created)
1194
1195- def makeCodeReviewVoteReference(self):
1196- bmp = removeSecurityProxy(self.makeBranchMergeProposal())
1197+ def makeCodeReviewVoteReference(self, git=False):
1198+ if git:
1199+ bmp = removeSecurityProxy(self.makeBranchMergeProposalForGit())
1200+ else:
1201+ bmp = removeSecurityProxy(self.makeBranchMergeProposal())
1202 candidate = self.makePerson()
1203 return bmp.nominateReviewer(candidate, bmp.registrant)
1204