Merge lp:~cjwatson/launchpad/git-repository-delete into lp:launchpad
- git-repository-delete
- Merge into devel
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 | ||||
Related bugs: |
|
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 |