Merge ~pappacena/launchpad:mp-refs-privacy-change into launchpad:master
- Git
- lp:~pappacena/launchpad
- mp-refs-privacy-change
- Merge into master
Proposed by
Thiago F. Pappacena
Status: | Superseded |
---|---|
Proposed branch: | ~pappacena/launchpad:mp-refs-privacy-change |
Merge into: | launchpad:master |
Diff against target: |
1280 lines (+639/-33) 17 files modified
lib/lp/code/configure.zcml (+9/-0) lib/lp/code/errors.py (+6/-1) lib/lp/code/interfaces/githosting.py (+19/-1) lib/lp/code/interfaces/gitjob.py (+16/-1) lib/lp/code/interfaces/gitrepository.py (+12/-1) lib/lp/code/model/branchmergeproposal.py (+75/-0) lib/lp/code/model/githosting.py (+63/-2) lib/lp/code/model/gitjob.py (+42/-1) lib/lp/code/model/gitrepository.py (+18/-2) lib/lp/code/model/tests/test_branchmergeproposal.py (+154/-0) lib/lp/code/model/tests/test_githosting.py (+45/-1) lib/lp/code/model/tests/test_gitjob.py (+36/-1) lib/lp/code/model/tests/test_gitrepository.py (+128/-19) lib/lp/code/subscribers/branchmergeproposal.py (+6/-2) lib/lp/code/tests/helpers.py (+2/-0) lib/lp/services/config/schema-lazr.conf (+4/-0) lib/lp/testing/fakemethod.py (+4/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Launchpad code reviewers | Pending | ||
Review via email: mp+390939@code.launchpad.net |
Commit message
Background job to add/remove merge proposal's virtual refs when a repository has its privacy changed
Description of the change
To post a comment you must log in.
Unmerged commits
- cca2a0f... by Thiago F. Pappacena
-
Merge branch 'create-mp-refs' into mp-refs-
privacy- change - 2ca86e6... by Thiago F. Pappacena
-
Fixing test
- ebd11bb... by Thiago F. Pappacena
-
Adding job to reconcile mp virtual refs
- 3c2ffb2... by Thiago F. Pappacena
-
Trigger MP reconciliation job when changing repository privacy setting
- 30c88c7... by Thiago F. Pappacena
-
Merge branch 'githosting-
copy-and- delete- refs' into create-mp-refs - a803723... by Thiago F. Pappacena
-
Fixing interface method definition
- 7e11cb0... by Thiago F. Pappacena
-
Refactoring virtual refs creation and preparing privacy code
- 1f7f371... by Thiago F. Pappacena
-
Adding feature flag and triggering copy on MP creation
- 50eeb2e... by Thiago F. Pappacena
-
Adding feature flag to control mp virtual refs creation
- 84d5c5f... by Thiago F. Pappacena
-
Logging error
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml |
2 | index 898e645..4adc8d0 100644 |
3 | --- a/lib/lp/code/configure.zcml |
4 | +++ b/lib/lp/code/configure.zcml |
5 | @@ -1111,6 +1111,11 @@ |
6 | provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource"> |
7 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" /> |
8 | </securedutility> |
9 | + <securedutility |
10 | + component="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob" |
11 | + provides="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource"> |
12 | + <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource" /> |
13 | + </securedutility> |
14 | <class class="lp.code.model.gitjob.GitRefScanJob"> |
15 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
16 | <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" /> |
17 | @@ -1123,6 +1128,10 @@ |
18 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
19 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" /> |
20 | </class> |
21 | + <class class="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob"> |
22 | + <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
23 | + <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJob" /> |
24 | + </class> |
25 | |
26 | <lp:help-folder folder="help" name="+help-code" /> |
27 | |
28 | diff --git a/lib/lp/code/errors.py b/lib/lp/code/errors.py |
29 | index 85de140..bed7db5 100644 |
30 | --- a/lib/lp/code/errors.py |
31 | +++ b/lib/lp/code/errors.py |
32 | @@ -1,4 +1,4 @@ |
33 | -# Copyright 2009-2019 Canonical Ltd. This software is licensed under the |
34 | +# Copyright 2009-2020 Canonical Ltd. This software is licensed under the |
35 | # GNU Affero General Public License version 3 (see the file LICENSE). |
36 | |
37 | """Errors used in the lp/code modules.""" |
38 | @@ -34,6 +34,7 @@ __all__ = [ |
39 | 'ClaimReviewFailed', |
40 | 'DiffNotFound', |
41 | 'GitDefaultConflict', |
42 | + 'GitReferenceDeletionFault', |
43 | 'GitRepositoryBlobNotFound', |
44 | 'GitRepositoryBlobUnsupportedRemote', |
45 | 'GitRepositoryCreationException', |
46 | @@ -493,6 +494,10 @@ class GitRepositoryDeletionFault(Exception): |
47 | """Raised when there is a fault deleting a repository.""" |
48 | |
49 | |
50 | +class GitReferenceDeletionFault(Exception): |
51 | + """Raised when there is a fault deleting a repository's ref.""" |
52 | + |
53 | + |
54 | class GitTargetError(Exception): |
55 | """Raised when there is an error determining a Git repository target.""" |
56 | |
57 | diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py |
58 | index 378930a..d7b048f 100644 |
59 | --- a/lib/lp/code/interfaces/githosting.py |
60 | +++ b/lib/lp/code/interfaces/githosting.py |
61 | @@ -1,4 +1,4 @@ |
62 | -# Copyright 2015-2018 Canonical Ltd. This software is licensed under the |
63 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
64 | # GNU Affero General Public License version 3 (see the file LICENSE). |
65 | |
66 | """Interface for communication with the Git hosting service.""" |
67 | @@ -129,3 +129,21 @@ class IGitHostingClient(Interface): |
68 | :param logger: An optional logger. |
69 | :return: A binary string with the blob content. |
70 | """ |
71 | + |
72 | + def copyRefs(path, operations, logger=None): |
73 | + """Executes the copy of refs or commits between different |
74 | + repositories. |
75 | + |
76 | + :param path: Physical path of the repository on the hosting service. |
77 | + :param operations: A list of RefCopyOperation objects describing |
78 | + source and target of the copy. |
79 | + :param logger: An optional logger. |
80 | + """ |
81 | + |
82 | + def deleteRef(path, ref, logger=None): |
83 | + """Deletes a reference on the given git repository. |
84 | + |
85 | + :param path: Physical path of the repository on the hosting service. |
86 | + :param ref: The reference to be delete. |
87 | + :param logger: An optional logger. |
88 | + """ |
89 | diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py |
90 | index 4f31b19..7c3c038 100644 |
91 | --- a/lib/lp/code/interfaces/gitjob.py |
92 | +++ b/lib/lp/code/interfaces/gitjob.py |
93 | @@ -1,4 +1,4 @@ |
94 | -# Copyright 2015 Canonical Ltd. This software is licensed under the |
95 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
96 | # GNU Affero General Public License version 3 (see the file LICENSE). |
97 | |
98 | """GitJob interfaces.""" |
99 | @@ -11,6 +11,8 @@ __all__ = [ |
100 | 'IGitRefScanJobSource', |
101 | 'IGitRepositoryModifiedMailJob', |
102 | 'IGitRepositoryModifiedMailJobSource', |
103 | + 'IGitRepositoryVirtualRefsSyncJob', |
104 | + 'IGitRepositoryVirtualRefsSyncJobSource', |
105 | 'IReclaimGitRepositorySpaceJob', |
106 | 'IReclaimGitRepositorySpaceJobSource', |
107 | ] |
108 | @@ -93,3 +95,16 @@ class IGitRepositoryModifiedMailJobSource(IJobSource): |
109 | :param repository_delta: An `IGitRepositoryDelta` describing the |
110 | changes. |
111 | """ |
112 | + |
113 | + |
114 | +class IGitRepositoryVirtualRefsSyncJob(IRunnableJob): |
115 | + """A job to synchronize all MPs virtual refs related to this repository.""" |
116 | + |
117 | + |
118 | +class IGitRepositoryVirtualRefsSyncJobSource(IJobSource): |
119 | + |
120 | + def create(repository): |
121 | + """Send email about repository modifications. |
122 | + |
123 | + :param repository: The `IGitRepository` that needs sync. |
124 | + """ |
125 | diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py |
126 | index 344bc43..9e4c129 100644 |
127 | --- a/lib/lp/code/interfaces/gitrepository.py |
128 | +++ b/lib/lp/code/interfaces/gitrepository.py |
129 | @@ -8,6 +8,8 @@ __metaclass__ = type |
130 | __all__ = [ |
131 | 'ContributorGitIdentity', |
132 | 'GitIdentityMixin', |
133 | + 'GIT_CREATE_MP_VIRTUAL_REF', |
134 | + 'GIT_MP_VIRTUAL_REF_FORMAT', |
135 | 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE', |
136 | 'git_repository_name_validator', |
137 | 'IGitRepository', |
138 | @@ -105,6 +107,11 @@ valid_git_repository_name_pattern = re.compile( |
139 | r"^(?i)[a-z0-9][a-z0-9+\.\-@_]*\Z") |
140 | |
141 | |
142 | +# Virtual ref where we automatically put a copy of any open merge proposal. |
143 | +GIT_MP_VIRTUAL_REF_FORMAT = 'refs/merge/{mp.id}/head' |
144 | +GIT_CREATE_MP_VIRTUAL_REF = 'git.mergeproposal_virtualref.enabled' |
145 | + |
146 | + |
147 | def valid_git_repository_name(name): |
148 | """Return True iff the name is valid as a Git repository name. |
149 | |
150 | @@ -595,11 +602,15 @@ class IGitRepositoryView(IHasRecipes): |
151 | def updateLandingTargets(paths): |
152 | """Update landing targets (MPs where this repository is the source). |
153 | |
154 | - For each merge proposal, create `UpdatePreviewDiffJob`s. |
155 | + For each merge proposal, create `UpdatePreviewDiffJob`s, and runs |
156 | + the appropriate GitHosting.copyRefs operation to allow having |
157 | + virtual merge refs with the updated branch version. |
158 | |
159 | :param paths: A list of reference paths. Any merge proposals whose |
160 | source is this repository and one of these paths will have their |
161 | diffs updated. |
162 | + :return: Returns a tuple with the list of background jobs created, |
163 | + and the lits of refs copy requested to GitHosting. |
164 | """ |
165 | |
166 | def markRecipesStale(paths): |
167 | diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py |
168 | index 2a346b4..bd804a8 100644 |
169 | --- a/lib/lp/code/model/branchmergeproposal.py |
170 | +++ b/lib/lp/code/model/branchmergeproposal.py |
171 | @@ -12,6 +12,7 @@ __all__ = [ |
172 | |
173 | from collections import defaultdict |
174 | from email.utils import make_msgid |
175 | +import logging |
176 | from operator import attrgetter |
177 | import sys |
178 | |
179 | @@ -83,7 +84,12 @@ from lp.code.interfaces.codereviewcomment import ICodeReviewComment |
180 | from lp.code.interfaces.codereviewinlinecomment import ( |
181 | ICodeReviewInlineCommentSet, |
182 | ) |
183 | +from lp.code.interfaces.githosting import IGitHostingClient |
184 | from lp.code.interfaces.gitref import IGitRef |
185 | +from lp.code.interfaces.gitrepository import ( |
186 | + GIT_CREATE_MP_VIRTUAL_REF, |
187 | + GIT_MP_VIRTUAL_REF_FORMAT, |
188 | + ) |
189 | from lp.code.mail.branch import RecipientReason |
190 | from lp.code.model.branchrevision import BranchRevision |
191 | from lp.code.model.codereviewcomment import CodeReviewComment |
192 | @@ -93,6 +99,7 @@ from lp.code.model.diff import ( |
193 | IncrementalDiff, |
194 | PreviewDiff, |
195 | ) |
196 | +from lp.code.model.githosting import RefCopyOperation |
197 | from lp.registry.interfaces.person import ( |
198 | IPerson, |
199 | IPersonSet, |
200 | @@ -122,6 +129,7 @@ from lp.services.database.sqlbase import ( |
201 | quote, |
202 | SQLBase, |
203 | ) |
204 | +from lp.services.features import getFeatureFlag |
205 | from lp.services.helpers import shortlist |
206 | from lp.services.job.interfaces.job import JobStatus |
207 | from lp.services.job.model.job import Job |
208 | @@ -143,6 +151,9 @@ from lp.soyuz.enums import ( |
209 | ) |
210 | |
211 | |
212 | +logger = logging.getLogger(__name__) |
213 | + |
214 | + |
215 | def is_valid_transition(proposal, from_state, next_state, user=None): |
216 | """Is it valid for this user to move this proposal to to next_state? |
217 | |
218 | @@ -968,6 +979,8 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): |
219 | branch_merge_proposal=self.id): |
220 | job.destroySelf() |
221 | self._preview_diffs.remove() |
222 | + self.deleteGitHostingVirtualRefs() |
223 | + |
224 | self.destroySelf() |
225 | |
226 | def getUnlandedSourceBranchRevisions(self): |
227 | @@ -1214,6 +1227,68 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): |
228 | for diff in diffs) |
229 | return [diff_dict.get(revisions) for revisions in revision_list] |
230 | |
231 | + def getGitHostingRefCopyOperations(self): |
232 | + """Gets the list of GitHosting copy operations that should be done |
233 | + in order to keep this MP's virtual refs up-to-date.""" |
234 | + if (not self.target_git_repository |
235 | + or not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF)): |
236 | + return [] |
237 | + # If the source repository is private and it's opening a MP to |
238 | + # another repository, we should not risk to to have the private code |
239 | + # undisclosed to everyone with permission to see the target repo: |
240 | + private_source = self.source_git_repository.private |
241 | + same_repo = self.source_git_repository == self.target_git_repository |
242 | + same_owner = self.source_git_repository.owner.inTeam( |
243 | + self.target_git_repository.owner) |
244 | + if private_source and not (same_repo or same_owner): |
245 | + return [] |
246 | + return [RefCopyOperation( |
247 | + self.source_git_commit_sha1, |
248 | + self.target_git_repository.getInternalPath(), |
249 | + GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))] |
250 | + |
251 | + def copyGitHostingVirtualRefs(self, logger=logger): |
252 | + """Requests virtual refs copy operations on GitHosting in order to |
253 | + keep them up-to-date with current MP's state. |
254 | + |
255 | + :return: The list of copy operations requested.""" |
256 | + copy_operations = self.getGitHostingRefCopyOperations() |
257 | + if copy_operations: |
258 | + hosting_client = getUtility(IGitHostingClient) |
259 | + hosting_client.copyRefs( |
260 | + self.source_git_repository.getInternalPath(), |
261 | + copy_operations, logger=logger) |
262 | + return copy_operations |
263 | + |
264 | + def deleteGitHostingVirtualRefs(self, except_refs=None, logger=None): |
265 | + """Deletes on git code hosting service all virtual refs, except |
266 | + those ones in the given list.""" |
267 | + if self.source_git_ref is None: |
268 | + return |
269 | + if not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF): |
270 | + # XXX pappacena 2020-09-15: Do not try to remove virtual refs if |
271 | + # the feature is disabled. It might be disabled due to bug on |
272 | + # the first versions, for example, and we should have a way to |
273 | + # skip this feature entirely. |
274 | + # Once the feature is stable, we should actually always delete |
275 | + # the virtual refs, since we don't know if the feature was |
276 | + # enabled or not when the branch was created. |
277 | + return |
278 | + ref = GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self) |
279 | + if ref not in (except_refs or []): |
280 | + hosting_client = getUtility(IGitHostingClient) |
281 | + hosting_client.deleteRef( |
282 | + self.target_git_repository.getInternalPath(), ref, |
283 | + logger=logger) |
284 | + |
285 | + def syncGitHostingVirtualRefs(self, logger=None): |
286 | + """Requests all copies and deletion of virtual refs to make git code |
287 | + hosting in sync with this MP.""" |
288 | + operations = self.copyGitHostingVirtualRefs(logger=logger) |
289 | + copied_refs = [i.target_ref for i in operations] |
290 | + self.deleteGitHostingVirtualRefs( |
291 | + except_refs=copied_refs, logger=logger) |
292 | + |
293 | def scheduleDiffUpdates(self, return_jobs=True): |
294 | """See `IBranchMergeProposal`.""" |
295 | from lp.code.model.branchmergeproposaljob import ( |
296 | diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py |
297 | index 94d6538..12a7d2f 100644 |
298 | --- a/lib/lp/code/model/githosting.py |
299 | +++ b/lib/lp/code/model/githosting.py |
300 | @@ -1,4 +1,4 @@ |
301 | -# Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
302 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
303 | # GNU Affero General Public License version 3 (see the file LICENSE). |
304 | |
305 | """Communication with the Git hosting service.""" |
306 | @@ -6,6 +6,7 @@ |
307 | __metaclass__ = type |
308 | __all__ = [ |
309 | 'GitHostingClient', |
310 | + 'RefCopyOperation', |
311 | ] |
312 | |
313 | import base64 |
314 | @@ -14,7 +15,10 @@ import sys |
315 | |
316 | from lazr.restful.utils import get_current_browser_request |
317 | import requests |
318 | -from six import reraise |
319 | +from six import ( |
320 | + ensure_text, |
321 | + reraise, |
322 | + ) |
323 | from six.moves.urllib.parse import ( |
324 | quote, |
325 | urljoin, |
326 | @@ -22,10 +26,12 @@ from six.moves.urllib.parse import ( |
327 | from zope.interface import implementer |
328 | |
329 | from lp.code.errors import ( |
330 | + GitReferenceDeletionFault, |
331 | GitRepositoryBlobNotFound, |
332 | GitRepositoryCreationFault, |
333 | GitRepositoryDeletionFault, |
334 | GitRepositoryScanFault, |
335 | + GitTargetError, |
336 | ) |
337 | from lp.code.interfaces.githosting import IGitHostingClient |
338 | from lp.services.config import config |
339 | @@ -41,6 +47,18 @@ class RequestExceptionWrapper(requests.RequestException): |
340 | """A non-requests exception that occurred during a request.""" |
341 | |
342 | |
343 | +class RefCopyOperation: |
344 | + """A description of a ref (or commit) copy between repositories. |
345 | + |
346 | + This class is just a helper to define copy operations parameters on |
347 | + IGitHostingClient.copyRefs method. |
348 | + """ |
349 | + def __init__(self, source_ref, target_repo, target_ref): |
350 | + self.source_ref = source_ref |
351 | + self.target_repo = target_repo |
352 | + self.target_ref = target_ref |
353 | + |
354 | + |
355 | @implementer(IGitHostingClient) |
356 | class GitHostingClient: |
357 | """A client for the internal API provided by the Git hosting system.""" |
358 | @@ -237,3 +255,46 @@ class GitHostingClient: |
359 | except Exception as e: |
360 | raise GitRepositoryScanFault( |
361 | "Failed to get file from Git repository: %s" % unicode(e)) |
362 | + |
363 | + def copyRefs(self, path, operations, logger=None): |
364 | + """See `IGitHostingClient`.""" |
365 | + json_data = { |
366 | + "operations": [{ |
367 | + "from": i.source_ref, |
368 | + "to": {"repo": i.target_repo, "ref": i.target_ref} |
369 | + } for i in operations] |
370 | + } |
371 | + try: |
372 | + if logger is not None: |
373 | + logger.info( |
374 | + "Copying refs from %s to %s targets" % |
375 | + (path, len(operations))) |
376 | + url = "/repo/%s/refs-copy" % path |
377 | + self._post(url, json=json_data) |
378 | + except requests.RequestException as e: |
379 | + if (e.response is not None and |
380 | + e.response.status_code == requests.codes.NOT_FOUND): |
381 | + raise GitTargetError( |
382 | + "Could not find repository %s or one of its refs" % |
383 | + ensure_text(path)) |
384 | + else: |
385 | + raise GitRepositoryScanFault( |
386 | + "Could not copy refs: HTTP %s" % e.response.status_code) |
387 | + |
388 | + def deleteRef(self, path, ref, logger=None): |
389 | + try: |
390 | + if logger is not None: |
391 | + logger.info("Delete from repo %s the ref %s" % (path, ref)) |
392 | + url = "/repo/%s/%s" % (path, ref) |
393 | + self._delete(url) |
394 | + except requests.RequestException as e: |
395 | + if (e.response is not None and |
396 | + e.response.status_code == requests.codes.NOT_FOUND): |
397 | + if logger is not None: |
398 | + logger.info( |
399 | + "Tried to delete a ref that doesn't exist: %s (%s)" % |
400 | + (path, ref)) |
401 | + return |
402 | + raise GitReferenceDeletionFault( |
403 | + "Error deleting %s from repo %s: HTTP %s" % |
404 | + (ref, path, e.response.status_code)) |
405 | diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py |
406 | index 3c041da..a18d28e 100644 |
407 | --- a/lib/lp/code/model/gitjob.py |
408 | +++ b/lib/lp/code/model/gitjob.py |
409 | @@ -1,4 +1,4 @@ |
410 | -# Copyright 2015-2018 Canonical Ltd. This software is licensed under the |
411 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
412 | # GNU Affero General Public License version 3 (see the file LICENSE). |
413 | |
414 | __metaclass__ = type |
415 | @@ -45,6 +45,8 @@ from lp.code.interfaces.gitjob import ( |
416 | IGitRefScanJobSource, |
417 | IGitRepositoryModifiedMailJob, |
418 | IGitRepositoryModifiedMailJobSource, |
419 | + IGitRepositoryVirtualRefsSyncJob, |
420 | + IGitRepositoryVirtualRefsSyncJobSource, |
421 | IReclaimGitRepositorySpaceJob, |
422 | IReclaimGitRepositorySpaceJobSource, |
423 | ) |
424 | @@ -100,6 +102,13 @@ class GitJobType(DBEnumeratedType): |
425 | modifications. |
426 | """) |
427 | |
428 | + SYNC_MP_VIRTUAL_REFS = DBItem(3, """ |
429 | + Sync merge proposals virtual refs |
430 | + |
431 | + This job runs against a repository to synchronize the virtual refs |
432 | + from all merge proposals related to this repository. |
433 | + """) |
434 | + |
435 | |
436 | @implementer(IGitJob) |
437 | class GitJob(StormBase): |
438 | @@ -404,3 +413,35 @@ class GitRepositoryModifiedMailJob(GitJobDerived): |
439 | def run(self): |
440 | """See `IGitRepositoryModifiedMailJob`.""" |
441 | self.getMailer().sendAll() |
442 | + |
443 | + |
444 | +@implementer(IGitRepositoryVirtualRefsSyncJob) |
445 | +@provider(IGitRepositoryVirtualRefsSyncJobSource) |
446 | +class GitRepositoryVirtualRefsSyncJob(GitJobDerived): |
447 | + """A Job that scans a Git repository for its current list of references.""" |
448 | + class_job_type = GitJobType.SYNC_MP_VIRTUAL_REFS |
449 | + |
450 | + max_retries = 5 |
451 | + |
452 | + retry_error_types = tuple() |
453 | + |
454 | + config = config.IGitRepositoryVirtualRefsSyncJobSource |
455 | + |
456 | + @classmethod |
457 | + def create(cls, repository): |
458 | + metadata = {} |
459 | + git_job = GitJob(repository, cls.class_job_type, metadata) |
460 | + job = cls(git_job) |
461 | + job.celeryRunOnCommit() |
462 | + return job |
463 | + |
464 | + def run(self): |
465 | + log.info( |
466 | + "Starting to re-sync virtual refs from repository %s", |
467 | + self.repository) |
468 | + for mp in self.repository.landing_targets: |
469 | + log.info("Re-syncing virtual refs from MP %s", mp) |
470 | + mp.syncGitHostingVirtualRefs(logger=log) |
471 | + log.info( |
472 | + "Finished re-syncing virtual refs from repository %s", |
473 | + self.repository) |
474 | diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py |
475 | index 77a4bbd..f9958c8 100644 |
476 | --- a/lib/lp/code/model/gitrepository.py |
477 | +++ b/lib/lp/code/model/gitrepository.py |
478 | @@ -116,7 +116,10 @@ from lp.code.interfaces.gitcollection import ( |
479 | IGitCollection, |
480 | ) |
481 | from lp.code.interfaces.githosting import IGitHostingClient |
482 | -from lp.code.interfaces.gitjob import IGitRefScanJobSource |
483 | +from lp.code.interfaces.gitjob import ( |
484 | + IGitRefScanJobSource, |
485 | + IGitRepositoryVirtualRefsSyncJobSource, |
486 | + ) |
487 | from lp.code.interfaces.gitlookup import IGitLookup |
488 | from lp.code.interfaces.gitnamespace import ( |
489 | get_git_namespace, |
490 | @@ -865,6 +868,7 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
491 | raise CannotChangeInformationType("Forbidden by project policy.") |
492 | # XXX cjwatson 2019-03-29: Check privacy rules on snaps that use |
493 | # this repository. |
494 | + was_private = self.private |
495 | self.information_type = information_type |
496 | self._reconcileAccess() |
497 | if (information_type in PRIVATE_INFORMATION_TYPES and |
498 | @@ -882,6 +886,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
499 | # subscriptions. |
500 | getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self]) |
501 | |
502 | + # If privacy changed, we need to re-sync all virtual refs from |
503 | + # all MPs to avoid disclosing private code, or to add the virtual |
504 | + # refs to the now public code. |
505 | + if was_private != self.private: |
506 | + getUtility(IGitRepositoryVirtualRefsSyncJobSource).create(self) |
507 | + |
508 | def setName(self, new_name, user): |
509 | """See `IGitRepository`.""" |
510 | self.namespace.moveRepository(self, user, new_name=new_name) |
511 | @@ -1160,9 +1170,15 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
512 | def updateLandingTargets(self, paths): |
513 | """See `IGitRepository`.""" |
514 | jobs = [] |
515 | + copy_operations = [] |
516 | for merge_proposal in self.getActiveLandingTargets(paths): |
517 | jobs.extend(merge_proposal.scheduleDiffUpdates()) |
518 | - return jobs |
519 | + copy_operations += merge_proposal.getGitHostingRefCopyOperations() |
520 | + |
521 | + if copy_operations: |
522 | + hosting_client = getUtility(IGitHostingClient) |
523 | + hosting_client.copyRefs(self.getInternalPath(), copy_operations) |
524 | + return jobs, copy_operations |
525 | |
526 | def _getRecipes(self, paths=None): |
527 | """Undecorated version of recipes for use by `markRecipesStale`.""" |
528 | diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py |
529 | index 0d2457d..277650c 100644 |
530 | --- a/lib/lp/code/model/tests/test_branchmergeproposal.py |
531 | +++ b/lib/lp/code/model/tests/test_branchmergeproposal.py |
532 | @@ -66,6 +66,10 @@ from lp.code.interfaces.branchmergeproposal import ( |
533 | IBranchMergeProposalGetter, |
534 | IBranchMergeProposalJobSource, |
535 | ) |
536 | +from lp.code.interfaces.gitrepository import ( |
537 | + GIT_CREATE_MP_VIRTUAL_REF, |
538 | + GIT_MP_VIRTUAL_REF_FORMAT, |
539 | + ) |
540 | from lp.code.model.branchmergeproposal import ( |
541 | BranchMergeProposal, |
542 | BranchMergeProposalGetter, |
543 | @@ -96,6 +100,7 @@ from lp.testing import ( |
544 | ExpectedException, |
545 | launchpadlib_for, |
546 | login, |
547 | + login_admin, |
548 | login_person, |
549 | person_logged_in, |
550 | TestCaseWithFactory, |
551 | @@ -255,6 +260,136 @@ class TestBranchMergeProposalPrivacy(TestCaseWithFactory): |
552 | self.assertContentEqual([owner, team], subscriptions) |
553 | |
554 | |
555 | +class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): |
556 | + """Ensure that BranchMergeProposal creation run the appropriate copy |
557 | + and delete of virtual refs, like ref/merge/<id>/head.""" |
558 | + |
559 | + layer = DatabaseFunctionalLayer |
560 | + |
561 | + def setUp(self): |
562 | + super(TestGitBranchMergeProposalVirtualRefs, self).setUp() |
563 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
564 | + |
565 | + def test_copy_git_merge_virtual_ref(self): |
566 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
567 | + mp = self.factory.makeBranchMergeProposalForGit() |
568 | + |
569 | + copy_operations = mp.getGitHostingRefCopyOperations() |
570 | + self.assertEqual(1, len(copy_operations)) |
571 | + self.assertThat(copy_operations[0], MatchesStructure( |
572 | + source_ref=Equals(mp.source_git_commit_sha1), |
573 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
574 | + target_ref=Equals("refs/merge/%s/head" % mp.id), |
575 | + )) |
576 | + |
577 | + self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
578 | + args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
579 | + arg_path, arg_copy_operations = args |
580 | + self.assertEqual({'logger': None}, kwargs) |
581 | + self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path) |
582 | + self.assertEqual(1, len(arg_copy_operations)) |
583 | + self.assertThat(arg_copy_operations[0], MatchesStructure( |
584 | + source_ref=Equals(mp.source_git_commit_sha1), |
585 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
586 | + target_ref=Equals("refs/merge/%s/head" % mp.id), |
587 | + )) |
588 | + |
589 | + def test_getGitHostingRefCopyOperations_private_source(self): |
590 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
591 | + login_admin() |
592 | + source_repo = self.factory.makeGitRepository( |
593 | + information_type=InformationType.PRIVATESECURITY) |
594 | + target_repo = self.factory.makeGitRepository(target=source_repo.target) |
595 | + [source] = self.factory.makeGitRefs(source_repo) |
596 | + [target] = self.factory.makeGitRefs(target_repo) |
597 | + mp = self.factory.makeBranchMergeProposalForGit( |
598 | + source_ref=source, target_ref=target) |
599 | + self.assertEqual([], mp.getGitHostingRefCopyOperations()) |
600 | + |
601 | + def test_getGitHostingRefCopyOperations_private_source_same_repo(self): |
602 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
603 | + login_admin() |
604 | + repo = self.factory.makeGitRepository( |
605 | + information_type=InformationType.PRIVATESECURITY) |
606 | + [source, target] = self.factory.makeGitRefs( |
607 | + repo, ['refs/heads/bugfix', 'refs/heads/master']) |
608 | + mp = self.factory.makeBranchMergeProposalForGit( |
609 | + source_ref=source, target_ref=target) |
610 | + operations = mp.getGitHostingRefCopyOperations() |
611 | + self.assertEqual(1, len(operations)) |
612 | + self.assertThat(operations[0], MatchesStructure( |
613 | + source_ref=Equals(mp.source_git_commit_sha1), |
614 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
615 | + target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp)) |
616 | + )) |
617 | + |
618 | + def test_getGitHostingRefCopyOperations_private_source_same_owner(self): |
619 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
620 | + login_admin() |
621 | + source_repo = self.factory.makeGitRepository( |
622 | + information_type=InformationType.PRIVATESECURITY) |
623 | + target_repo = self.factory.makeGitRepository( |
624 | + target=source_repo.target, owner=source_repo.owner) |
625 | + [source] = self.factory.makeGitRefs(source_repo) |
626 | + [target] = self.factory.makeGitRefs(target_repo) |
627 | + mp = self.factory.makeBranchMergeProposalForGit( |
628 | + source_ref=source, target_ref=target) |
629 | + operations = mp.getGitHostingRefCopyOperations() |
630 | + self.assertEqual(1, len(operations)) |
631 | + self.assertThat(operations[0], MatchesStructure( |
632 | + source_ref=Equals(mp.source_git_commit_sha1), |
633 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
634 | + target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp)) |
635 | + )) |
636 | + |
637 | + def test_syncGitHostingVirtualRefs(self): |
638 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
639 | + login_admin() |
640 | + login_admin() |
641 | + source_repo = self.factory.makeGitRepository() |
642 | + target_repo = self.factory.makeGitRepository(target=source_repo.target) |
643 | + [source] = self.factory.makeGitRefs(source_repo) |
644 | + [target] = self.factory.makeGitRefs(target_repo) |
645 | + mp = self.factory.makeBranchMergeProposalForGit( |
646 | + source_ref=source, target_ref=target) |
647 | + |
648 | + # mp.syncGitHostingVirtualRefs should have been triggered by event. |
649 | + # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created. |
650 | + self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
651 | + args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
652 | + self.assertEquals({'logger': None}, kwargs) |
653 | + self.assertEqual(args[0], source_repo.getInternalPath()) |
654 | + self.assertEqual(1, len(args[1])) |
655 | + self.assertThat(args[1][0], MatchesStructure( |
656 | + source_ref=Equals(mp.source_git_commit_sha1), |
657 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
658 | + target_ref=Equals("refs/merge/%s/head" % mp.id), |
659 | + )) |
660 | + |
661 | + self.assertEqual(0, self.hosting_fixture.deleteRef.call_count) |
662 | + |
663 | + def test_syncGitHostingVirtualRefs_private_source_deletes_ref(self): |
664 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
665 | + login_admin() |
666 | + source_repo = self.factory.makeGitRepository( |
667 | + information_type=InformationType.PRIVATESECURITY) |
668 | + target_repo = self.factory.makeGitRepository(target=source_repo.target) |
669 | + [source] = self.factory.makeGitRefs(source_repo) |
670 | + [target] = self.factory.makeGitRefs(target_repo) |
671 | + mp = self.factory.makeBranchMergeProposalForGit( |
672 | + source_ref=source, target_ref=target) |
673 | + |
674 | + # mp.syncGitHostingVirtualRefs should have been triggered by event. |
675 | + # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created. |
676 | + self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
677 | + self.assertEqual(1, self.hosting_fixture.deleteRef.call_count) |
678 | + args, kwargs = self.hosting_fixture.deleteRef.calls[0] |
679 | + self.assertEqual({'logger': None}, kwargs) |
680 | + self.assertEqual( |
681 | + (target_repo.getInternalPath(), "refs/merge/%s/head" % mp.id), |
682 | + args) |
683 | + |
684 | + |
685 | class TestBranchMergeProposalTransitions(TestCaseWithFactory): |
686 | """Test the state transitions of branch merge proposals.""" |
687 | |
688 | @@ -1184,6 +1319,10 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory): |
689 | def test_delete_triggers_webhooks(self): |
690 | # When an existing merge proposal is deleted, any relevant webhooks |
691 | # are triggered. |
692 | + self.useFixture(FeatureFixture({ |
693 | + GIT_CREATE_MP_VIRTUAL_REF: "on", |
694 | + BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG: "on"})) |
695 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
696 | logger = self.useFixture(FakeLogger()) |
697 | source = self.makeBranch() |
698 | target = self.makeBranch(same_target_as=source) |
699 | @@ -1204,8 +1343,11 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory): |
700 | old=MatchesDict(self.getExpectedPayload(proposal, redact=True))) |
701 | proposal.deleteProposal() |
702 | delivery = hook.deliveries.one() |
703 | + self.assertIsNotNone(delivery) |
704 | self.assertCorrectDelivery(expected_payload, hook, delivery) |
705 | self.assertCorrectLogging(expected_redacted_payload, hook, logger) |
706 | + self.assertEqual( |
707 | + 1 if self.git else 0, hosting_fixture.deleteRef.call_count) |
708 | |
709 | |
710 | class TestGetAddress(TestCaseWithFactory): |
711 | @@ -1506,6 +1648,18 @@ class TestBranchMergeProposalDeletion(TestCaseWithFactory): |
712 | self.assertRaises( |
713 | SQLObjectNotFound, BranchMergeProposalJob.get, job_id) |
714 | |
715 | + def test_deleteProposal_for_git_removes_virtual_ref(self): |
716 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
717 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
718 | + proposal = self.factory.makeBranchMergeProposalForGit() |
719 | + proposal.deleteProposal() |
720 | + |
721 | + self.assertEqual(1, hosting_fixture.deleteRef.call_count) |
722 | + args = hosting_fixture.deleteRef.calls[0] |
723 | + self.assertEqual(( |
724 | + (proposal.target_git_repository.getInternalPath(), |
725 | + 'refs/merge/%s/head' % proposal.id), {'logger': None}), args) |
726 | + |
727 | |
728 | class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory): |
729 | |
730 | diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py |
731 | index d966dbe..04f7a8e 100644 |
732 | --- a/lib/lp/code/model/tests/test_githosting.py |
733 | +++ b/lib/lp/code/model/tests/test_githosting.py |
734 | @@ -2,7 +2,7 @@ |
735 | # NOTE: The first line above must stay first; do not move the copyright |
736 | # notice to the top. See http://www.python.org/dev/peps/pep-0263/. |
737 | # |
738 | -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the |
739 | +# Copyright 2016-2020 Canonical Ltd. This software is licensed under the |
740 | # GNU Affero General Public License version 3 (see the file LICENSE). |
741 | |
742 | """Unit tests for `GitHostingClient`. |
743 | @@ -37,12 +37,16 @@ from zope.interface import implementer |
744 | from zope.security.proxy import removeSecurityProxy |
745 | |
746 | from lp.code.errors import ( |
747 | + GitReferenceDeletionFault, |
748 | GitRepositoryBlobNotFound, |
749 | GitRepositoryCreationFault, |
750 | GitRepositoryDeletionFault, |
751 | GitRepositoryScanFault, |
752 | + GitTargetError, |
753 | + NoSuchGitReference, |
754 | ) |
755 | from lp.code.interfaces.githosting import IGitHostingClient |
756 | +from lp.code.model.githosting import RefCopyOperation |
757 | from lp.services.job.interfaces.job import ( |
758 | IRunnableJob, |
759 | JobStatus, |
760 | @@ -400,6 +404,46 @@ class TestGitHostingClient(TestCase): |
761 | " (256 vs 0)", |
762 | self.client.getBlob, "123", "dir/path/file/name") |
763 | |
764 | + def getCopyRefOperations(self): |
765 | + return [ |
766 | + RefCopyOperation("1a2b3c4", "999", "refs/merge/123"), |
767 | + RefCopyOperation("9a8b7c6", "666", "refs/merge/989"), |
768 | + ] |
769 | + |
770 | + def test_copyRefs(self): |
771 | + with self.mockRequests("POST", status=202): |
772 | + self.client.copyRefs("123", self.getCopyRefOperations()) |
773 | + self.assertRequest("repo/123/refs-copy", { |
774 | + "operations": [ |
775 | + { |
776 | + "from": "1a2b3c4", |
777 | + "to": {"repo": "999", "ref": "refs/merge/123"} |
778 | + }, { |
779 | + "from": "9a8b7c6", |
780 | + "to": {"repo": "666", "ref": "refs/merge/989"} |
781 | + } |
782 | + ] |
783 | + }, "POST") |
784 | + |
785 | + def test_copyRefs_refs_not_found(self): |
786 | + with self.mockRequests("POST", status=404): |
787 | + self.assertRaisesWithContent( |
788 | + GitTargetError, |
789 | + "Could not find repository 123 or one of its refs", |
790 | + self.client.copyRefs, "123", self.getCopyRefOperations()) |
791 | + |
792 | + def test_deleteRef(self): |
793 | + with self.mockRequests("DELETE", status=202): |
794 | + self.client.deleteRef("123", "refs/merge/123") |
795 | + self.assertRequest("repo/123/refs/merge/123", method="DELETE") |
796 | + |
797 | + def test_deleteRef_refs_request_error(self): |
798 | + with self.mockRequests("DELETE", status=500): |
799 | + self.assertRaisesWithContent( |
800 | + GitReferenceDeletionFault, |
801 | + "Error deleting refs/merge/123 from repo 123: HTTP 500", |
802 | + self.client.deleteRef, "123", "refs/merge/123") |
803 | + |
804 | def test_works_in_job(self): |
805 | # `GitHostingClient` is usable from a running job. |
806 | @implementer(IRunnableJob) |
807 | diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py |
808 | index 5964944..cc6ac49 100644 |
809 | --- a/lib/lp/code/model/tests/test_gitjob.py |
810 | +++ b/lib/lp/code/model/tests/test_gitjob.py |
811 | @@ -1,4 +1,4 @@ |
812 | -# Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
813 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
814 | # GNU Affero General Public License version 3 (see the file LICENSE). |
815 | |
816 | """Tests for `GitJob`s.""" |
817 | @@ -16,6 +16,7 @@ import hashlib |
818 | from fixtures import FakeLogger |
819 | from lazr.lifecycle.snapshot import Snapshot |
820 | import pytz |
821 | +from storm.store import Store |
822 | from testtools.matchers import ( |
823 | ContainsDict, |
824 | Equals, |
825 | @@ -24,9 +25,11 @@ from testtools.matchers import ( |
826 | MatchesStructure, |
827 | ) |
828 | import transaction |
829 | +from zope.component import getUtility |
830 | from zope.interface import providedBy |
831 | from zope.security.proxy import removeSecurityProxy |
832 | |
833 | +from lp.app.enums import InformationType |
834 | from lp.code.adapters.gitrepository import GitRepositoryDelta |
835 | from lp.code.enums import ( |
836 | GitGranteeType, |
837 | @@ -38,8 +41,10 @@ from lp.code.interfaces.branchmergeproposal import ( |
838 | from lp.code.interfaces.gitjob import ( |
839 | IGitJob, |
840 | IGitRefScanJob, |
841 | + IGitRepositoryVirtualRefsSyncJobSource, |
842 | IReclaimGitRepositorySpaceJob, |
843 | ) |
844 | +from lp.code.interfaces.gitrepository import GIT_CREATE_MP_VIRTUAL_REF |
845 | from lp.code.model.gitjob import ( |
846 | describe_repository_delta, |
847 | GitJob, |
848 | @@ -484,5 +489,35 @@ class TestDescribeRepositoryDelta(TestCaseWithFactory): |
849 | snapshot, repository) |
850 | |
851 | |
852 | +class TestGitRepositoryVirtualRefsSyncJob(TestCaseWithFactory): |
853 | + """Tests for `GitRepositoryVirtualRefsSyncJob`.""" |
854 | + |
855 | + layer = ZopelessDatabaseLayer |
856 | + |
857 | + def test_changing_repo_to_private_deletes_refs(self): |
858 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
859 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
860 | + mp = self.factory.makeBranchMergeProposalForGit() |
861 | + source_repo = mp.source_git_repository |
862 | + target_repo = mp.target_git_repository |
863 | + source_repo.transitionToInformationType( |
864 | + InformationType.PRIVATESECURITY, source_repo.owner, False) |
865 | + Store.of(source_repo).flush() |
866 | + |
867 | + hosting_fixture.copyRefs.resetCalls() |
868 | + hosting_fixture.deleteRef.resetCalls() |
869 | + with dbuser("branchscanner"): |
870 | + job_set = JobRunner.fromReady( |
871 | + getUtility(IGitRepositoryVirtualRefsSyncJobSource)) |
872 | + job_set.runAll() |
873 | + self.assertEqual(1, len(job_set.completed_jobs)) |
874 | + |
875 | + self.assertEqual(0, hosting_fixture.copyRefs.call_count) |
876 | + self.assertEqual(1, hosting_fixture.deleteRef.call_count) |
877 | + args, kwargs = hosting_fixture.deleteRef.calls[0] |
878 | + self.assertEqual( |
879 | + (target_repo.getInternalPath(), 'refs/merge/%s/head' % mp.id), |
880 | + args) |
881 | + |
882 | # XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too, |
883 | # but that isn't feasible until we have a proper turnip fixture. |
884 | diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py |
885 | index 62bf865..6a9fa20 100644 |
886 | --- a/lib/lp/code/model/tests/test_gitrepository.py |
887 | +++ b/lib/lp/code/model/tests/test_gitrepository.py |
888 | @@ -18,6 +18,7 @@ from datetime import ( |
889 | import email |
890 | from functools import partial |
891 | import hashlib |
892 | +import itertools |
893 | import json |
894 | |
895 | from breezy import urlutils |
896 | @@ -89,6 +90,7 @@ from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository |
897 | from lp.code.interfaces.gitjob import ( |
898 | IGitRefScanJobSource, |
899 | IGitRepositoryModifiedMailJobSource, |
900 | + IGitRepositoryVirtualRefsSyncJobSource, |
901 | ) |
902 | from lp.code.interfaces.gitlookup import IGitLookup |
903 | from lp.code.interfaces.gitnamespace import ( |
904 | @@ -96,6 +98,7 @@ from lp.code.interfaces.gitnamespace import ( |
905 | IGitNamespaceSet, |
906 | ) |
907 | from lp.code.interfaces.gitrepository import ( |
908 | + GIT_CREATE_MP_VIRTUAL_REF, |
909 | IGitRepository, |
910 | IGitRepositorySet, |
911 | IGitRepositoryView, |
912 | @@ -113,6 +116,7 @@ from lp.code.model.branchmergeproposaljob import ( |
913 | ) |
914 | from lp.code.model.codereviewcomment import CodeReviewComment |
915 | from lp.code.model.gitactivity import GitActivity |
916 | +from lp.code.model.githosting import RefCopyOperation |
917 | from lp.code.model.gitjob import ( |
918 | GitJob, |
919 | GitJobType, |
920 | @@ -146,6 +150,7 @@ from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory |
921 | from lp.registry.interfaces.personproduct import IPersonProductFactory |
922 | from lp.registry.tests.test_accesspolicy import get_policies_for_artifact |
923 | from lp.services.authserver.xmlrpc import AuthServerAPIView |
924 | +from lp.services.compat import mock |
925 | from lp.services.config import config |
926 | from lp.services.database.constants import UTC_NOW |
927 | from lp.services.database.interfaces import IStore |
928 | @@ -180,6 +185,7 @@ from lp.testing import ( |
929 | verifyObject, |
930 | ) |
931 | from lp.testing.dbuser import dbuser |
932 | +from lp.testing.fixture import ZopeUtilityFixture |
933 | from lp.testing.layers import ( |
934 | DatabaseFunctionalLayer, |
935 | LaunchpadFunctionalLayer, |
936 | @@ -782,6 +788,7 @@ class TestGitRepositoryDeletion(TestCaseWithFactory): |
937 | # Make sure that the tests all flush the database changes. |
938 | self.addCleanup(Store.of(self.repository).flush) |
939 | login_person(self.user) |
940 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
941 | |
942 | def test_deletable(self): |
943 | # A newly created repository can be deleted without any problems. |
944 | @@ -823,7 +830,6 @@ class TestGitRepositoryDeletion(TestCaseWithFactory): |
945 | |
946 | def test_code_import_does_not_disable_deletion(self): |
947 | # A repository that has an attached code import can be deleted. |
948 | - self.useFixture(GitHostingFixture()) |
949 | code_import = self.factory.makeCodeImport( |
950 | target_rcs_type=TargetRevisionControlSystems.GIT) |
951 | repository = code_import.git_repository |
952 | @@ -983,6 +989,7 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
953 | # unsubscribe the repository owner here. |
954 | self.repository.unsubscribe( |
955 | self.repository.owner, self.repository.owner) |
956 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
957 | |
958 | def test_plain_repository(self): |
959 | # A fresh repository has no deletion requirements. |
960 | @@ -1114,7 +1121,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
961 | |
962 | def test_code_import_requirements(self): |
963 | # Code imports are not included explicitly in deletion requirements. |
964 | - self.useFixture(GitHostingFixture()) |
965 | code_import = self.factory.makeCodeImport( |
966 | target_rcs_type=TargetRevisionControlSystems.GIT) |
967 | # Remove the implicit repository subscription first. |
968 | @@ -1125,7 +1131,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
969 | |
970 | def test_code_import_deletion(self): |
971 | # break_references allows deleting a code import repository. |
972 | - self.useFixture(GitHostingFixture()) |
973 | code_import = self.factory.makeCodeImport( |
974 | target_rcs_type=TargetRevisionControlSystems.GIT) |
975 | code_import_id = code_import.id |
976 | @@ -1181,7 +1186,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
977 | |
978 | def test_DeleteCodeImport(self): |
979 | # DeleteCodeImport.__call__ must delete the CodeImport. |
980 | - self.useFixture(GitHostingFixture()) |
981 | code_import = self.factory.makeCodeImport( |
982 | target_rcs_type=TargetRevisionControlSystems.GIT) |
983 | code_import_id = code_import.id |
984 | @@ -1520,6 +1524,10 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
985 | |
986 | layer = DatabaseFunctionalLayer |
987 | |
988 | + def setUp(self): |
989 | + super(TestGitRepositoryRefs, self).setUp() |
990 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
991 | + |
992 | def test__convertRefInfo(self): |
993 | # _convertRefInfo converts a valid info dictionary. |
994 | sha1 = unicode(hashlib.sha1("").hexdigest()) |
995 | @@ -1788,18 +1796,17 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
996 | # planRefChanges excludes some ref prefixes by default, and can be |
997 | # configured otherwise. |
998 | repository = self.factory.makeGitRepository() |
999 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
1000 | repository.planRefChanges("dummy") |
1001 | self.assertEqual( |
1002 | [{"exclude_prefixes": ["refs/changes/"]}], |
1003 | - hosting_fixture.getRefs.extract_kwargs()) |
1004 | - hosting_fixture.getRefs.calls = [] |
1005 | + self.hosting_fixture.getRefs.extract_kwargs()) |
1006 | + self.hosting_fixture.getRefs.calls = [] |
1007 | self.pushConfig( |
1008 | "codehosting", git_exclude_ref_prefixes="refs/changes/ refs/pull/") |
1009 | repository.planRefChanges("dummy") |
1010 | self.assertEqual( |
1011 | [{"exclude_prefixes": ["refs/changes/", "refs/pull/"]}], |
1012 | - hosting_fixture.getRefs.extract_kwargs()) |
1013 | + self.hosting_fixture.getRefs.extract_kwargs()) |
1014 | |
1015 | def test_fetchRefCommits(self): |
1016 | # fetchRefCommits fetches detailed tip commit metadata for the |
1017 | @@ -1871,9 +1878,8 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
1018 | def test_fetchRefCommits_empty(self): |
1019 | # If given an empty refs dictionary, fetchRefCommits returns early |
1020 | # without contacting the hosting service. |
1021 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
1022 | GitRepository.fetchRefCommits("dummy", {}) |
1023 | - self.assertEqual([], hosting_fixture.getCommits.calls) |
1024 | + self.assertEqual([], self.hosting_fixture.getCommits.calls) |
1025 | |
1026 | def test_synchroniseRefs(self): |
1027 | # synchroniseRefs copes with synchronising a repository where some |
1028 | @@ -1916,7 +1922,6 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
1029 | self.assertThat(repository.refs, MatchesSetwise(*matchers)) |
1030 | |
1031 | def test_set_default_branch(self): |
1032 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
1033 | repository = self.factory.makeGitRepository() |
1034 | self.factory.makeGitRefs( |
1035 | repository=repository, |
1036 | @@ -1927,22 +1932,20 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
1037 | self.assertEqual( |
1038 | [((repository.getInternalPath(),), |
1039 | {"default_branch": "refs/heads/new"})], |
1040 | - hosting_fixture.setProperties.calls) |
1041 | + self.hosting_fixture.setProperties.calls) |
1042 | self.assertEqual("refs/heads/new", repository.default_branch) |
1043 | |
1044 | def test_set_default_branch_unchanged(self): |
1045 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
1046 | repository = self.factory.makeGitRepository() |
1047 | self.factory.makeGitRefs( |
1048 | repository=repository, paths=["refs/heads/master"]) |
1049 | removeSecurityProxy(repository)._default_branch = "refs/heads/master" |
1050 | with person_logged_in(repository.owner): |
1051 | repository.default_branch = "master" |
1052 | - self.assertEqual([], hosting_fixture.setProperties.calls) |
1053 | + self.assertEqual([], self.hosting_fixture.setProperties.calls) |
1054 | self.assertEqual("refs/heads/master", repository.default_branch) |
1055 | |
1056 | def test_set_default_branch_imported(self): |
1057 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
1058 | repository = self.factory.makeGitRepository( |
1059 | repository_type=GitRepositoryType.IMPORTED) |
1060 | self.factory.makeGitRefs( |
1061 | @@ -1955,7 +1958,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
1062 | "Cannot modify non-hosted Git repository %s." % |
1063 | repository.display_name, |
1064 | setattr, repository, "default_branch", "new") |
1065 | - self.assertEqual([], hosting_fixture.setProperties.calls) |
1066 | + self.assertEqual([], self.hosting_fixture.setProperties.calls) |
1067 | self.assertEqual("refs/heads/master", repository.default_branch) |
1068 | |
1069 | def test_exception_unset_default_branch(self): |
1070 | @@ -2576,18 +2579,59 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory): |
1071 | |
1072 | layer = DatabaseFunctionalLayer |
1073 | |
1074 | - def test_schedules_diff_updates(self): |
1075 | + def setUp(self): |
1076 | + super(TestGitRepositoryUpdateLandingTargets, self).setUp() |
1077 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
1078 | + |
1079 | + def assertSchedulesDiffUpdate(self, with_mp_virtual_ref): |
1080 | """Create jobs for all merge proposals.""" |
1081 | bmp1 = self.factory.makeBranchMergeProposalForGit() |
1082 | bmp2 = self.factory.makeBranchMergeProposalForGit( |
1083 | source_ref=bmp1.source_git_ref) |
1084 | - jobs = bmp1.source_git_repository.updateLandingTargets( |
1085 | + |
1086 | + # Only enable this virtual ref here, since |
1087 | + # self.factory.makeBranchMergeProposalForGit also tries to create |
1088 | + # the virtual refs. |
1089 | + if with_mp_virtual_ref: |
1090 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
1091 | + else: |
1092 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: ""})) |
1093 | + jobs, ref_copies = bmp1.source_git_repository.updateLandingTargets( |
1094 | [bmp1.source_git_path]) |
1095 | self.assertEqual(2, len(jobs)) |
1096 | bmps_to_update = [ |
1097 | removeSecurityProxy(job).branch_merge_proposal for job in jobs] |
1098 | self.assertContentEqual([bmp1, bmp2], bmps_to_update) |
1099 | |
1100 | + if not with_mp_virtual_ref: |
1101 | + self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
1102 | + else: |
1103 | + self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
1104 | + args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
1105 | + self.assertEqual({}, kwargs) |
1106 | + self.assertEqual(2, len(args)) |
1107 | + path, operations = args |
1108 | + self.assertEqual( |
1109 | + path, bmp1.source_git_repository.getInternalPath()) |
1110 | + self.assertThat(operations[0], MatchesStructure( |
1111 | + source_ref=Equals(bmp1.source_git_commit_sha1), |
1112 | + target_repo=Equals( |
1113 | + bmp1.target_git_repository.getInternalPath()), |
1114 | + target_ref=Equals("refs/merge/%s/head" % bmp1.id), |
1115 | + )) |
1116 | + self.assertThat(operations[1], MatchesStructure( |
1117 | + source_ref=Equals(bmp2.source_git_commit_sha1), |
1118 | + target_repo=Equals( |
1119 | + bmp2.target_git_repository.getInternalPath()), |
1120 | + target_ref=Equals("refs/merge/%s/head" % bmp2.id), |
1121 | + )) |
1122 | + |
1123 | + def test_schedules_diff_updates_with_mp_virtual_ref(self): |
1124 | + self.assertSchedulesDiffUpdate(True) |
1125 | + |
1126 | + def test_schedules_diff_updates_without_mp_virtual_ref(self): |
1127 | + self.assertSchedulesDiffUpdate(False) |
1128 | + |
1129 | def test_ignores_final(self): |
1130 | """Diffs for proposals in final states aren't updated.""" |
1131 | [source_ref] = self.factory.makeGitRefs() |
1132 | @@ -2599,8 +2643,15 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory): |
1133 | for bmp in source_ref.landing_targets: |
1134 | if bmp.queue_status not in FINAL_STATES: |
1135 | removeSecurityProxy(bmp).deleteProposal() |
1136 | - jobs = source_ref.repository.updateLandingTargets([source_ref.path]) |
1137 | + |
1138 | + # Enable the feature here, since factory.makeBranchMergeProposalForGit |
1139 | + # would also trigger the copy refs call. |
1140 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
1141 | + jobs, ref_copies = source_ref.repository.updateLandingTargets( |
1142 | + [source_ref.path]) |
1143 | self.assertEqual(0, len(jobs)) |
1144 | + self.assertEqual(0, len(ref_copies)) |
1145 | + self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
1146 | |
1147 | |
1148 | class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): |
1149 | @@ -4616,3 +4667,61 @@ class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory): |
1150 | ["Caveat check for '%s' failed." % |
1151 | find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id], |
1152 | issuer, macaroon2, repository, user=repository.owner) |
1153 | + |
1154 | + |
1155 | +class TestGitRepositoryPrivacyChangeSyncVirtualRefs(TestCaseWithFactory): |
1156 | + layer = DatabaseFunctionalLayer |
1157 | + |
1158 | + def assertChangePrivacyTriggersSync( |
1159 | + self, from_list, to_list, should_trigger_sync=True): |
1160 | + """Runs repository.transitionToInformationType from every item in |
1161 | + `from_list` to each item in `to_list`, and checks if the virtual |
1162 | + refs sync was triggered or not, depending on `should_trigger_sync`.""" |
1163 | + sync_job = mock.Mock() |
1164 | + self.useFixture(ZopeUtilityFixture( |
1165 | + sync_job, IGitRepositoryVirtualRefsSyncJobSource)) |
1166 | + |
1167 | + admin = self.factory.makeAdministrator() |
1168 | + login_person(admin) |
1169 | + for from_type, to_type in itertools.product(from_list, to_list): |
1170 | + if from_type == to_type: |
1171 | + continue |
1172 | + repository = self.factory.makeGitRepository() |
1173 | + naked_repo = removeSecurityProxy(repository) |
1174 | + naked_repo.information_type = from_type |
1175 | + # Skip access policy reconciliation. |
1176 | + naked_repo._reconcileAccess = mock.Mock() |
1177 | + naked_repo.transitionToInformationType(to_type, admin, False) |
1178 | + |
1179 | + if should_trigger_sync: |
1180 | + sync_job.create.assert_called_with(repository) |
1181 | + else: |
1182 | + self.assertEqual( |
1183 | + 0, sync_job.create.call_count, |
1184 | + "Changing from %s to %s should't trigger vrefs sync" |
1185 | + % (from_type, to_type)) |
1186 | + sync_job.reset_mock() |
1187 | + |
1188 | + def test_setting_repo_public_triggers_ref_sync_job(self): |
1189 | + self.assertChangePrivacyTriggersSync( |
1190 | + PRIVATE_INFORMATION_TYPES, |
1191 | + PUBLIC_INFORMATION_TYPES, |
1192 | + should_trigger_sync=True) |
1193 | + |
1194 | + def test_setting_repo_private_triggers_ref_sync_job(self): |
1195 | + self.assertChangePrivacyTriggersSync( |
1196 | + PUBLIC_INFORMATION_TYPES, |
1197 | + PRIVATE_INFORMATION_TYPES, |
1198 | + should_trigger_sync=True) |
1199 | + |
1200 | + def test_keeping_repo_private_dont_trigger_ref_sync_job(self): |
1201 | + self.assertChangePrivacyTriggersSync( |
1202 | + PRIVATE_INFORMATION_TYPES, |
1203 | + PRIVATE_INFORMATION_TYPES, |
1204 | + should_trigger_sync=False) |
1205 | + |
1206 | + def test_keeping_repo_public_dont_trigger_ref_sync_job(self): |
1207 | + self.assertChangePrivacyTriggersSync( |
1208 | + PUBLIC_INFORMATION_TYPES, |
1209 | + PUBLIC_INFORMATION_TYPES, |
1210 | + should_trigger_sync=False) |
1211 | diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py |
1212 | index a214c8f..b394388 100644 |
1213 | --- a/lib/lp/code/subscribers/branchmergeproposal.py |
1214 | +++ b/lib/lp/code/subscribers/branchmergeproposal.py |
1215 | @@ -1,4 +1,4 @@ |
1216 | -# Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
1217 | +# Copyright 2010-2020 Canonical Ltd. This software is licensed under the |
1218 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1219 | |
1220 | """Event subscribers for branch merge proposals.""" |
1221 | @@ -63,9 +63,13 @@ def _trigger_webhook(merge_proposal, payload): |
1222 | def merge_proposal_created(merge_proposal, event): |
1223 | """A new merge proposal has been created. |
1224 | |
1225 | - Create a job to update the diff for the merge proposal; trigger webhooks. |
1226 | + Create a job to update the diff for the merge proposal; trigger webhooks |
1227 | + and copy virtual refs as needed. |
1228 | """ |
1229 | getUtility(IUpdatePreviewDiffJobSource).create(merge_proposal) |
1230 | + |
1231 | + merge_proposal.syncGitHostingVirtualRefs() |
1232 | + |
1233 | if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG): |
1234 | payload = { |
1235 | "action": "created", |
1236 | diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py |
1237 | index 0e784f0..90a7a87 100644 |
1238 | --- a/lib/lp/code/tests/helpers.py |
1239 | +++ b/lib/lp/code/tests/helpers.py |
1240 | @@ -368,6 +368,8 @@ class GitHostingFixture(fixtures.Fixture): |
1241 | self.getBlob = FakeMethod(result=blob) |
1242 | self.delete = FakeMethod() |
1243 | self.disable_memcache = disable_memcache |
1244 | + self.copyRefs = FakeMethod() |
1245 | + self.deleteRef = FakeMethod() |
1246 | |
1247 | def _setUp(self): |
1248 | self.useFixture(ZopeUtilityFixture(self, IGitHostingClient)) |
1249 | diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf |
1250 | index bb8afcd..3921da2 100644 |
1251 | --- a/lib/lp/services/config/schema-lazr.conf |
1252 | +++ b/lib/lp/services/config/schema-lazr.conf |
1253 | @@ -1828,6 +1828,10 @@ module: lp.code.interfaces.gitjob |
1254 | dbuser: send-branch-mail |
1255 | crontab_group: MAIN |
1256 | |
1257 | +[IGitRepositoryVirtualRefsSyncJobSource] |
1258 | +module: lp.code.interfaces.gitjob |
1259 | +dbuser: branchscanner |
1260 | + |
1261 | [IInitializeDistroSeriesJobSource] |
1262 | module: lp.soyuz.interfaces.distributionjob |
1263 | dbuser: initializedistroseries |
1264 | diff --git a/lib/lp/testing/fakemethod.py b/lib/lp/testing/fakemethod.py |
1265 | index 4bba8d3..b4895ce 100644 |
1266 | --- a/lib/lp/testing/fakemethod.py |
1267 | +++ b/lib/lp/testing/fakemethod.py |
1268 | @@ -1,4 +1,4 @@ |
1269 | -# Copyright 2009-2011 Canonical Ltd. This software is licensed under the |
1270 | +# Copyright 2009-2020 Canonical Ltd. This software is licensed under the |
1271 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1272 | |
1273 | __metaclass__ = type |
1274 | @@ -57,3 +57,6 @@ class FakeMethod: |
1275 | def extract_kwargs(self): |
1276 | """Return just the calls' keyword-arguments dicts.""" |
1277 | return [kwargs for args, kwargs in self.calls] |
1278 | + |
1279 | + def resetCalls(self): |
1280 | + self.calls = [] |