Merge ~pappacena/launchpad:mp-refs-privacy-change into launchpad: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)
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

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
1diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
2index 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
28diff --git a/lib/lp/code/errors.py b/lib/lp/code/errors.py
29index 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
57diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py
58index 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+ """
89diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py
90index 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+ """
125diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
126index 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):
167diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py
168index 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 (
296diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py
297index 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))
405diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py
406index 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)
474diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
475index 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`."""
528diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py
529index 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
730diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
731index 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)
807diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py
808index 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.
884diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
885index 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)
1211diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py
1212index 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",
1236diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py
1237index 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))
1249diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
1250index 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
1264diff --git a/lib/lp/testing/fakemethod.py b/lib/lp/testing/fakemethod.py
1265index 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 = []