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
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 898e645..4adc8d0 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1111,6 +1111,11 @@
1111 provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource">1111 provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource">
1112 <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" />1112 <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" />
1113 </securedutility>1113 </securedutility>
1114 <securedutility
1115 component="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob"
1116 provides="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource">
1117 <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource" />
1118 </securedutility>
1114 <class class="lp.code.model.gitjob.GitRefScanJob">1119 <class class="lp.code.model.gitjob.GitRefScanJob">
1115 <allow interface="lp.code.interfaces.gitjob.IGitJob" />1120 <allow interface="lp.code.interfaces.gitjob.IGitJob" />
1116 <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />1121 <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
@@ -1123,6 +1128,10 @@
1123 <allow interface="lp.code.interfaces.gitjob.IGitJob" />1128 <allow interface="lp.code.interfaces.gitjob.IGitJob" />
1124 <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" />1129 <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" />
1125 </class>1130 </class>
1131 <class class="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob">
1132 <allow interface="lp.code.interfaces.gitjob.IGitJob" />
1133 <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJob" />
1134 </class>
11261135
1127 <lp:help-folder folder="help" name="+help-code" />1136 <lp:help-folder folder="help" name="+help-code" />
11281137
diff --git a/lib/lp/code/errors.py b/lib/lp/code/errors.py
index 85de140..bed7db5 100644
--- a/lib/lp/code/errors.py
+++ b/lib/lp/code/errors.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Errors used in the lp/code modules."""4"""Errors used in the lp/code modules."""
@@ -34,6 +34,7 @@ __all__ = [
34 'ClaimReviewFailed',34 'ClaimReviewFailed',
35 'DiffNotFound',35 'DiffNotFound',
36 'GitDefaultConflict',36 'GitDefaultConflict',
37 'GitReferenceDeletionFault',
37 'GitRepositoryBlobNotFound',38 'GitRepositoryBlobNotFound',
38 'GitRepositoryBlobUnsupportedRemote',39 'GitRepositoryBlobUnsupportedRemote',
39 'GitRepositoryCreationException',40 'GitRepositoryCreationException',
@@ -493,6 +494,10 @@ class GitRepositoryDeletionFault(Exception):
493 """Raised when there is a fault deleting a repository."""494 """Raised when there is a fault deleting a repository."""
494495
495496
497class GitReferenceDeletionFault(Exception):
498 """Raised when there is a fault deleting a repository's ref."""
499
500
496class GitTargetError(Exception):501class GitTargetError(Exception):
497 """Raised when there is an error determining a Git repository target."""502 """Raised when there is an error determining a Git repository target."""
498503
diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py
index 378930a..d7b048f 100644
--- a/lib/lp/code/interfaces/githosting.py
+++ b/lib/lp/code/interfaces/githosting.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2018 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Interface for communication with the Git hosting service."""4"""Interface for communication with the Git hosting service."""
@@ -129,3 +129,21 @@ class IGitHostingClient(Interface):
129 :param logger: An optional logger.129 :param logger: An optional logger.
130 :return: A binary string with the blob content.130 :return: A binary string with the blob content.
131 """131 """
132
133 def copyRefs(path, operations, logger=None):
134 """Executes the copy of refs or commits between different
135 repositories.
136
137 :param path: Physical path of the repository on the hosting service.
138 :param operations: A list of RefCopyOperation objects describing
139 source and target of the copy.
140 :param logger: An optional logger.
141 """
142
143 def deleteRef(path, ref, logger=None):
144 """Deletes a reference on the given git repository.
145
146 :param path: Physical path of the repository on the hosting service.
147 :param ref: The reference to be delete.
148 :param logger: An optional logger.
149 """
diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py
index 4f31b19..7c3c038 100644
--- a/lib/lp/code/interfaces/gitjob.py
+++ b/lib/lp/code/interfaces/gitjob.py
@@ -1,4 +1,4 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""GitJob interfaces."""4"""GitJob interfaces."""
@@ -11,6 +11,8 @@ __all__ = [
11 'IGitRefScanJobSource',11 'IGitRefScanJobSource',
12 'IGitRepositoryModifiedMailJob',12 'IGitRepositoryModifiedMailJob',
13 'IGitRepositoryModifiedMailJobSource',13 'IGitRepositoryModifiedMailJobSource',
14 'IGitRepositoryVirtualRefsSyncJob',
15 'IGitRepositoryVirtualRefsSyncJobSource',
14 'IReclaimGitRepositorySpaceJob',16 'IReclaimGitRepositorySpaceJob',
15 'IReclaimGitRepositorySpaceJobSource',17 'IReclaimGitRepositorySpaceJobSource',
16 ]18 ]
@@ -93,3 +95,16 @@ class IGitRepositoryModifiedMailJobSource(IJobSource):
93 :param repository_delta: An `IGitRepositoryDelta` describing the95 :param repository_delta: An `IGitRepositoryDelta` describing the
94 changes.96 changes.
95 """97 """
98
99
100class IGitRepositoryVirtualRefsSyncJob(IRunnableJob):
101 """A job to synchronize all MPs virtual refs related to this repository."""
102
103
104class IGitRepositoryVirtualRefsSyncJobSource(IJobSource):
105
106 def create(repository):
107 """Send email about repository modifications.
108
109 :param repository: The `IGitRepository` that needs sync.
110 """
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 344bc43..9e4c129 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -8,6 +8,8 @@ __metaclass__ = type
8__all__ = [8__all__ = [
9 'ContributorGitIdentity',9 'ContributorGitIdentity',
10 'GitIdentityMixin',10 'GitIdentityMixin',
11 'GIT_CREATE_MP_VIRTUAL_REF',
12 'GIT_MP_VIRTUAL_REF_FORMAT',
11 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',13 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',
12 'git_repository_name_validator',14 'git_repository_name_validator',
13 'IGitRepository',15 'IGitRepository',
@@ -105,6 +107,11 @@ valid_git_repository_name_pattern = re.compile(
105 r"^(?i)[a-z0-9][a-z0-9+\.\-@_]*\Z")107 r"^(?i)[a-z0-9][a-z0-9+\.\-@_]*\Z")
106108
107109
110# Virtual ref where we automatically put a copy of any open merge proposal.
111GIT_MP_VIRTUAL_REF_FORMAT = 'refs/merge/{mp.id}/head'
112GIT_CREATE_MP_VIRTUAL_REF = 'git.mergeproposal_virtualref.enabled'
113
114
108def valid_git_repository_name(name):115def valid_git_repository_name(name):
109 """Return True iff the name is valid as a Git repository name.116 """Return True iff the name is valid as a Git repository name.
110117
@@ -595,11 +602,15 @@ class IGitRepositoryView(IHasRecipes):
595 def updateLandingTargets(paths):602 def updateLandingTargets(paths):
596 """Update landing targets (MPs where this repository is the source).603 """Update landing targets (MPs where this repository is the source).
597604
598 For each merge proposal, create `UpdatePreviewDiffJob`s.605 For each merge proposal, create `UpdatePreviewDiffJob`s, and runs
606 the appropriate GitHosting.copyRefs operation to allow having
607 virtual merge refs with the updated branch version.
599608
600 :param paths: A list of reference paths. Any merge proposals whose609 :param paths: A list of reference paths. Any merge proposals whose
601 source is this repository and one of these paths will have their610 source is this repository and one of these paths will have their
602 diffs updated.611 diffs updated.
612 :return: Returns a tuple with the list of background jobs created,
613 and the lits of refs copy requested to GitHosting.
603 """614 """
604615
605 def markRecipesStale(paths):616 def markRecipesStale(paths):
diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py
index 2a346b4..bd804a8 100644
--- a/lib/lp/code/model/branchmergeproposal.py
+++ b/lib/lp/code/model/branchmergeproposal.py
@@ -12,6 +12,7 @@ __all__ = [
1212
13from collections import defaultdict13from collections import defaultdict
14from email.utils import make_msgid14from email.utils import make_msgid
15import logging
15from operator import attrgetter16from operator import attrgetter
16import sys17import sys
1718
@@ -83,7 +84,12 @@ from lp.code.interfaces.codereviewcomment import ICodeReviewComment
83from lp.code.interfaces.codereviewinlinecomment import (84from lp.code.interfaces.codereviewinlinecomment import (
84 ICodeReviewInlineCommentSet,85 ICodeReviewInlineCommentSet,
85 )86 )
87from lp.code.interfaces.githosting import IGitHostingClient
86from lp.code.interfaces.gitref import IGitRef88from lp.code.interfaces.gitref import IGitRef
89from lp.code.interfaces.gitrepository import (
90 GIT_CREATE_MP_VIRTUAL_REF,
91 GIT_MP_VIRTUAL_REF_FORMAT,
92 )
87from lp.code.mail.branch import RecipientReason93from lp.code.mail.branch import RecipientReason
88from lp.code.model.branchrevision import BranchRevision94from lp.code.model.branchrevision import BranchRevision
89from lp.code.model.codereviewcomment import CodeReviewComment95from lp.code.model.codereviewcomment import CodeReviewComment
@@ -93,6 +99,7 @@ from lp.code.model.diff import (
93 IncrementalDiff,99 IncrementalDiff,
94 PreviewDiff,100 PreviewDiff,
95 )101 )
102from lp.code.model.githosting import RefCopyOperation
96from lp.registry.interfaces.person import (103from lp.registry.interfaces.person import (
97 IPerson,104 IPerson,
98 IPersonSet,105 IPersonSet,
@@ -122,6 +129,7 @@ from lp.services.database.sqlbase import (
122 quote,129 quote,
123 SQLBase,130 SQLBase,
124 )131 )
132from lp.services.features import getFeatureFlag
125from lp.services.helpers import shortlist133from lp.services.helpers import shortlist
126from lp.services.job.interfaces.job import JobStatus134from lp.services.job.interfaces.job import JobStatus
127from lp.services.job.model.job import Job135from lp.services.job.model.job import Job
@@ -143,6 +151,9 @@ from lp.soyuz.enums import (
143 )151 )
144152
145153
154logger = logging.getLogger(__name__)
155
156
146def is_valid_transition(proposal, from_state, next_state, user=None):157def is_valid_transition(proposal, from_state, next_state, user=None):
147 """Is it valid for this user to move this proposal to to next_state?158 """Is it valid for this user to move this proposal to to next_state?
148159
@@ -968,6 +979,8 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
968 branch_merge_proposal=self.id):979 branch_merge_proposal=self.id):
969 job.destroySelf()980 job.destroySelf()
970 self._preview_diffs.remove()981 self._preview_diffs.remove()
982 self.deleteGitHostingVirtualRefs()
983
971 self.destroySelf()984 self.destroySelf()
972985
973 def getUnlandedSourceBranchRevisions(self):986 def getUnlandedSourceBranchRevisions(self):
@@ -1214,6 +1227,68 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
1214 for diff in diffs)1227 for diff in diffs)
1215 return [diff_dict.get(revisions) for revisions in revision_list]1228 return [diff_dict.get(revisions) for revisions in revision_list]
12161229
1230 def getGitHostingRefCopyOperations(self):
1231 """Gets the list of GitHosting copy operations that should be done
1232 in order to keep this MP's virtual refs up-to-date."""
1233 if (not self.target_git_repository
1234 or not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF)):
1235 return []
1236 # If the source repository is private and it's opening a MP to
1237 # another repository, we should not risk to to have the private code
1238 # undisclosed to everyone with permission to see the target repo:
1239 private_source = self.source_git_repository.private
1240 same_repo = self.source_git_repository == self.target_git_repository
1241 same_owner = self.source_git_repository.owner.inTeam(
1242 self.target_git_repository.owner)
1243 if private_source and not (same_repo or same_owner):
1244 return []
1245 return [RefCopyOperation(
1246 self.source_git_commit_sha1,
1247 self.target_git_repository.getInternalPath(),
1248 GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))]
1249
1250 def copyGitHostingVirtualRefs(self, logger=logger):
1251 """Requests virtual refs copy operations on GitHosting in order to
1252 keep them up-to-date with current MP's state.
1253
1254 :return: The list of copy operations requested."""
1255 copy_operations = self.getGitHostingRefCopyOperations()
1256 if copy_operations:
1257 hosting_client = getUtility(IGitHostingClient)
1258 hosting_client.copyRefs(
1259 self.source_git_repository.getInternalPath(),
1260 copy_operations, logger=logger)
1261 return copy_operations
1262
1263 def deleteGitHostingVirtualRefs(self, except_refs=None, logger=None):
1264 """Deletes on git code hosting service all virtual refs, except
1265 those ones in the given list."""
1266 if self.source_git_ref is None:
1267 return
1268 if not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF):
1269 # XXX pappacena 2020-09-15: Do not try to remove virtual refs if
1270 # the feature is disabled. It might be disabled due to bug on
1271 # the first versions, for example, and we should have a way to
1272 # skip this feature entirely.
1273 # Once the feature is stable, we should actually always delete
1274 # the virtual refs, since we don't know if the feature was
1275 # enabled or not when the branch was created.
1276 return
1277 ref = GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self)
1278 if ref not in (except_refs or []):
1279 hosting_client = getUtility(IGitHostingClient)
1280 hosting_client.deleteRef(
1281 self.target_git_repository.getInternalPath(), ref,
1282 logger=logger)
1283
1284 def syncGitHostingVirtualRefs(self, logger=None):
1285 """Requests all copies and deletion of virtual refs to make git code
1286 hosting in sync with this MP."""
1287 operations = self.copyGitHostingVirtualRefs(logger=logger)
1288 copied_refs = [i.target_ref for i in operations]
1289 self.deleteGitHostingVirtualRefs(
1290 except_refs=copied_refs, logger=logger)
1291
1217 def scheduleDiffUpdates(self, return_jobs=True):1292 def scheduleDiffUpdates(self, return_jobs=True):
1218 """See `IBranchMergeProposal`."""1293 """See `IBranchMergeProposal`."""
1219 from lp.code.model.branchmergeproposaljob import (1294 from lp.code.model.branchmergeproposaljob import (
diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py
index 94d6538..12a7d2f 100644
--- a/lib/lp/code/model/githosting.py
+++ b/lib/lp/code/model/githosting.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Communication with the Git hosting service."""4"""Communication with the Git hosting service."""
@@ -6,6 +6,7 @@
6__metaclass__ = type6__metaclass__ = type
7__all__ = [7__all__ = [
8 'GitHostingClient',8 'GitHostingClient',
9 'RefCopyOperation',
9 ]10 ]
1011
11import base6412import base64
@@ -14,7 +15,10 @@ import sys
1415
15from lazr.restful.utils import get_current_browser_request16from lazr.restful.utils import get_current_browser_request
16import requests17import requests
17from six import reraise18from six import (
19 ensure_text,
20 reraise,
21 )
18from six.moves.urllib.parse import (22from six.moves.urllib.parse import (
19 quote,23 quote,
20 urljoin,24 urljoin,
@@ -22,10 +26,12 @@ from six.moves.urllib.parse import (
22from zope.interface import implementer26from zope.interface import implementer
2327
24from lp.code.errors import (28from lp.code.errors import (
29 GitReferenceDeletionFault,
25 GitRepositoryBlobNotFound,30 GitRepositoryBlobNotFound,
26 GitRepositoryCreationFault,31 GitRepositoryCreationFault,
27 GitRepositoryDeletionFault,32 GitRepositoryDeletionFault,
28 GitRepositoryScanFault,33 GitRepositoryScanFault,
34 GitTargetError,
29 )35 )
30from lp.code.interfaces.githosting import IGitHostingClient36from lp.code.interfaces.githosting import IGitHostingClient
31from lp.services.config import config37from lp.services.config import config
@@ -41,6 +47,18 @@ class RequestExceptionWrapper(requests.RequestException):
41 """A non-requests exception that occurred during a request."""47 """A non-requests exception that occurred during a request."""
4248
4349
50class RefCopyOperation:
51 """A description of a ref (or commit) copy between repositories.
52
53 This class is just a helper to define copy operations parameters on
54 IGitHostingClient.copyRefs method.
55 """
56 def __init__(self, source_ref, target_repo, target_ref):
57 self.source_ref = source_ref
58 self.target_repo = target_repo
59 self.target_ref = target_ref
60
61
44@implementer(IGitHostingClient)62@implementer(IGitHostingClient)
45class GitHostingClient:63class GitHostingClient:
46 """A client for the internal API provided by the Git hosting system."""64 """A client for the internal API provided by the Git hosting system."""
@@ -237,3 +255,46 @@ class GitHostingClient:
237 except Exception as e:255 except Exception as e:
238 raise GitRepositoryScanFault(256 raise GitRepositoryScanFault(
239 "Failed to get file from Git repository: %s" % unicode(e))257 "Failed to get file from Git repository: %s" % unicode(e))
258
259 def copyRefs(self, path, operations, logger=None):
260 """See `IGitHostingClient`."""
261 json_data = {
262 "operations": [{
263 "from": i.source_ref,
264 "to": {"repo": i.target_repo, "ref": i.target_ref}
265 } for i in operations]
266 }
267 try:
268 if logger is not None:
269 logger.info(
270 "Copying refs from %s to %s targets" %
271 (path, len(operations)))
272 url = "/repo/%s/refs-copy" % path
273 self._post(url, json=json_data)
274 except requests.RequestException as e:
275 if (e.response is not None and
276 e.response.status_code == requests.codes.NOT_FOUND):
277 raise GitTargetError(
278 "Could not find repository %s or one of its refs" %
279 ensure_text(path))
280 else:
281 raise GitRepositoryScanFault(
282 "Could not copy refs: HTTP %s" % e.response.status_code)
283
284 def deleteRef(self, path, ref, logger=None):
285 try:
286 if logger is not None:
287 logger.info("Delete from repo %s the ref %s" % (path, ref))
288 url = "/repo/%s/%s" % (path, ref)
289 self._delete(url)
290 except requests.RequestException as e:
291 if (e.response is not None and
292 e.response.status_code == requests.codes.NOT_FOUND):
293 if logger is not None:
294 logger.info(
295 "Tried to delete a ref that doesn't exist: %s (%s)" %
296 (path, ref))
297 return
298 raise GitReferenceDeletionFault(
299 "Error deleting %s from repo %s: HTTP %s" %
300 (ref, path, e.response.status_code))
diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py
index 3c041da..a18d28e 100644
--- a/lib/lp/code/model/gitjob.py
+++ b/lib/lp/code/model/gitjob.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2018 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -45,6 +45,8 @@ from lp.code.interfaces.gitjob import (
45 IGitRefScanJobSource,45 IGitRefScanJobSource,
46 IGitRepositoryModifiedMailJob,46 IGitRepositoryModifiedMailJob,
47 IGitRepositoryModifiedMailJobSource,47 IGitRepositoryModifiedMailJobSource,
48 IGitRepositoryVirtualRefsSyncJob,
49 IGitRepositoryVirtualRefsSyncJobSource,
48 IReclaimGitRepositorySpaceJob,50 IReclaimGitRepositorySpaceJob,
49 IReclaimGitRepositorySpaceJobSource,51 IReclaimGitRepositorySpaceJobSource,
50 )52 )
@@ -100,6 +102,13 @@ class GitJobType(DBEnumeratedType):
100 modifications.102 modifications.
101 """)103 """)
102104
105 SYNC_MP_VIRTUAL_REFS = DBItem(3, """
106 Sync merge proposals virtual refs
107
108 This job runs against a repository to synchronize the virtual refs
109 from all merge proposals related to this repository.
110 """)
111
103112
104@implementer(IGitJob)113@implementer(IGitJob)
105class GitJob(StormBase):114class GitJob(StormBase):
@@ -404,3 +413,35 @@ class GitRepositoryModifiedMailJob(GitJobDerived):
404 def run(self):413 def run(self):
405 """See `IGitRepositoryModifiedMailJob`."""414 """See `IGitRepositoryModifiedMailJob`."""
406 self.getMailer().sendAll()415 self.getMailer().sendAll()
416
417
418@implementer(IGitRepositoryVirtualRefsSyncJob)
419@provider(IGitRepositoryVirtualRefsSyncJobSource)
420class GitRepositoryVirtualRefsSyncJob(GitJobDerived):
421 """A Job that scans a Git repository for its current list of references."""
422 class_job_type = GitJobType.SYNC_MP_VIRTUAL_REFS
423
424 max_retries = 5
425
426 retry_error_types = tuple()
427
428 config = config.IGitRepositoryVirtualRefsSyncJobSource
429
430 @classmethod
431 def create(cls, repository):
432 metadata = {}
433 git_job = GitJob(repository, cls.class_job_type, metadata)
434 job = cls(git_job)
435 job.celeryRunOnCommit()
436 return job
437
438 def run(self):
439 log.info(
440 "Starting to re-sync virtual refs from repository %s",
441 self.repository)
442 for mp in self.repository.landing_targets:
443 log.info("Re-syncing virtual refs from MP %s", mp)
444 mp.syncGitHostingVirtualRefs(logger=log)
445 log.info(
446 "Finished re-syncing virtual refs from repository %s",
447 self.repository)
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 77a4bbd..f9958c8 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -116,7 +116,10 @@ from lp.code.interfaces.gitcollection import (
116 IGitCollection,116 IGitCollection,
117 )117 )
118from lp.code.interfaces.githosting import IGitHostingClient118from lp.code.interfaces.githosting import IGitHostingClient
119from lp.code.interfaces.gitjob import IGitRefScanJobSource119from lp.code.interfaces.gitjob import (
120 IGitRefScanJobSource,
121 IGitRepositoryVirtualRefsSyncJobSource,
122 )
120from lp.code.interfaces.gitlookup import IGitLookup123from lp.code.interfaces.gitlookup import IGitLookup
121from lp.code.interfaces.gitnamespace import (124from lp.code.interfaces.gitnamespace import (
122 get_git_namespace,125 get_git_namespace,
@@ -865,6 +868,7 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
865 raise CannotChangeInformationType("Forbidden by project policy.")868 raise CannotChangeInformationType("Forbidden by project policy.")
866 # XXX cjwatson 2019-03-29: Check privacy rules on snaps that use869 # XXX cjwatson 2019-03-29: Check privacy rules on snaps that use
867 # this repository.870 # this repository.
871 was_private = self.private
868 self.information_type = information_type872 self.information_type = information_type
869 self._reconcileAccess()873 self._reconcileAccess()
870 if (information_type in PRIVATE_INFORMATION_TYPES and874 if (information_type in PRIVATE_INFORMATION_TYPES and
@@ -882,6 +886,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
882 # subscriptions.886 # subscriptions.
883 getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self])887 getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self])
884888
889 # If privacy changed, we need to re-sync all virtual refs from
890 # all MPs to avoid disclosing private code, or to add the virtual
891 # refs to the now public code.
892 if was_private != self.private:
893 getUtility(IGitRepositoryVirtualRefsSyncJobSource).create(self)
894
885 def setName(self, new_name, user):895 def setName(self, new_name, user):
886 """See `IGitRepository`."""896 """See `IGitRepository`."""
887 self.namespace.moveRepository(self, user, new_name=new_name)897 self.namespace.moveRepository(self, user, new_name=new_name)
@@ -1160,9 +1170,15 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
1160 def updateLandingTargets(self, paths):1170 def updateLandingTargets(self, paths):
1161 """See `IGitRepository`."""1171 """See `IGitRepository`."""
1162 jobs = []1172 jobs = []
1173 copy_operations = []
1163 for merge_proposal in self.getActiveLandingTargets(paths):1174 for merge_proposal in self.getActiveLandingTargets(paths):
1164 jobs.extend(merge_proposal.scheduleDiffUpdates())1175 jobs.extend(merge_proposal.scheduleDiffUpdates())
1165 return jobs1176 copy_operations += merge_proposal.getGitHostingRefCopyOperations()
1177
1178 if copy_operations:
1179 hosting_client = getUtility(IGitHostingClient)
1180 hosting_client.copyRefs(self.getInternalPath(), copy_operations)
1181 return jobs, copy_operations
11661182
1167 def _getRecipes(self, paths=None):1183 def _getRecipes(self, paths=None):
1168 """Undecorated version of recipes for use by `markRecipesStale`."""1184 """Undecorated version of recipes for use by `markRecipesStale`."""
diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py
index 0d2457d..277650c 100644
--- a/lib/lp/code/model/tests/test_branchmergeproposal.py
+++ b/lib/lp/code/model/tests/test_branchmergeproposal.py
@@ -66,6 +66,10 @@ from lp.code.interfaces.branchmergeproposal import (
66 IBranchMergeProposalGetter,66 IBranchMergeProposalGetter,
67 IBranchMergeProposalJobSource,67 IBranchMergeProposalJobSource,
68 )68 )
69from lp.code.interfaces.gitrepository import (
70 GIT_CREATE_MP_VIRTUAL_REF,
71 GIT_MP_VIRTUAL_REF_FORMAT,
72 )
69from lp.code.model.branchmergeproposal import (73from lp.code.model.branchmergeproposal import (
70 BranchMergeProposal,74 BranchMergeProposal,
71 BranchMergeProposalGetter,75 BranchMergeProposalGetter,
@@ -96,6 +100,7 @@ from lp.testing import (
96 ExpectedException,100 ExpectedException,
97 launchpadlib_for,101 launchpadlib_for,
98 login,102 login,
103 login_admin,
99 login_person,104 login_person,
100 person_logged_in,105 person_logged_in,
101 TestCaseWithFactory,106 TestCaseWithFactory,
@@ -255,6 +260,136 @@ class TestBranchMergeProposalPrivacy(TestCaseWithFactory):
255 self.assertContentEqual([owner, team], subscriptions)260 self.assertContentEqual([owner, team], subscriptions)
256261
257262
263class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory):
264 """Ensure that BranchMergeProposal creation run the appropriate copy
265 and delete of virtual refs, like ref/merge/<id>/head."""
266
267 layer = DatabaseFunctionalLayer
268
269 def setUp(self):
270 super(TestGitBranchMergeProposalVirtualRefs, self).setUp()
271 self.hosting_fixture = self.useFixture(GitHostingFixture())
272
273 def test_copy_git_merge_virtual_ref(self):
274 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
275 mp = self.factory.makeBranchMergeProposalForGit()
276
277 copy_operations = mp.getGitHostingRefCopyOperations()
278 self.assertEqual(1, len(copy_operations))
279 self.assertThat(copy_operations[0], MatchesStructure(
280 source_ref=Equals(mp.source_git_commit_sha1),
281 target_repo=Equals(mp.target_git_repository.getInternalPath()),
282 target_ref=Equals("refs/merge/%s/head" % mp.id),
283 ))
284
285 self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
286 args, kwargs = self.hosting_fixture.copyRefs.calls[0]
287 arg_path, arg_copy_operations = args
288 self.assertEqual({'logger': None}, kwargs)
289 self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path)
290 self.assertEqual(1, len(arg_copy_operations))
291 self.assertThat(arg_copy_operations[0], MatchesStructure(
292 source_ref=Equals(mp.source_git_commit_sha1),
293 target_repo=Equals(mp.target_git_repository.getInternalPath()),
294 target_ref=Equals("refs/merge/%s/head" % mp.id),
295 ))
296
297 def test_getGitHostingRefCopyOperations_private_source(self):
298 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
299 login_admin()
300 source_repo = self.factory.makeGitRepository(
301 information_type=InformationType.PRIVATESECURITY)
302 target_repo = self.factory.makeGitRepository(target=source_repo.target)
303 [source] = self.factory.makeGitRefs(source_repo)
304 [target] = self.factory.makeGitRefs(target_repo)
305 mp = self.factory.makeBranchMergeProposalForGit(
306 source_ref=source, target_ref=target)
307 self.assertEqual([], mp.getGitHostingRefCopyOperations())
308
309 def test_getGitHostingRefCopyOperations_private_source_same_repo(self):
310 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
311 login_admin()
312 repo = self.factory.makeGitRepository(
313 information_type=InformationType.PRIVATESECURITY)
314 [source, target] = self.factory.makeGitRefs(
315 repo, ['refs/heads/bugfix', 'refs/heads/master'])
316 mp = self.factory.makeBranchMergeProposalForGit(
317 source_ref=source, target_ref=target)
318 operations = mp.getGitHostingRefCopyOperations()
319 self.assertEqual(1, len(operations))
320 self.assertThat(operations[0], MatchesStructure(
321 source_ref=Equals(mp.source_git_commit_sha1),
322 target_repo=Equals(mp.target_git_repository.getInternalPath()),
323 target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
324 ))
325
326 def test_getGitHostingRefCopyOperations_private_source_same_owner(self):
327 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
328 login_admin()
329 source_repo = self.factory.makeGitRepository(
330 information_type=InformationType.PRIVATESECURITY)
331 target_repo = self.factory.makeGitRepository(
332 target=source_repo.target, owner=source_repo.owner)
333 [source] = self.factory.makeGitRefs(source_repo)
334 [target] = self.factory.makeGitRefs(target_repo)
335 mp = self.factory.makeBranchMergeProposalForGit(
336 source_ref=source, target_ref=target)
337 operations = mp.getGitHostingRefCopyOperations()
338 self.assertEqual(1, len(operations))
339 self.assertThat(operations[0], MatchesStructure(
340 source_ref=Equals(mp.source_git_commit_sha1),
341 target_repo=Equals(mp.target_git_repository.getInternalPath()),
342 target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
343 ))
344
345 def test_syncGitHostingVirtualRefs(self):
346 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
347 login_admin()
348 login_admin()
349 source_repo = self.factory.makeGitRepository()
350 target_repo = self.factory.makeGitRepository(target=source_repo.target)
351 [source] = self.factory.makeGitRefs(source_repo)
352 [target] = self.factory.makeGitRefs(target_repo)
353 mp = self.factory.makeBranchMergeProposalForGit(
354 source_ref=source, target_ref=target)
355
356 # mp.syncGitHostingVirtualRefs should have been triggered by event.
357 # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
358 self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
359 args, kwargs = self.hosting_fixture.copyRefs.calls[0]
360 self.assertEquals({'logger': None}, kwargs)
361 self.assertEqual(args[0], source_repo.getInternalPath())
362 self.assertEqual(1, len(args[1]))
363 self.assertThat(args[1][0], MatchesStructure(
364 source_ref=Equals(mp.source_git_commit_sha1),
365 target_repo=Equals(mp.target_git_repository.getInternalPath()),
366 target_ref=Equals("refs/merge/%s/head" % mp.id),
367 ))
368
369 self.assertEqual(0, self.hosting_fixture.deleteRef.call_count)
370
371 def test_syncGitHostingVirtualRefs_private_source_deletes_ref(self):
372 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
373 login_admin()
374 source_repo = self.factory.makeGitRepository(
375 information_type=InformationType.PRIVATESECURITY)
376 target_repo = self.factory.makeGitRepository(target=source_repo.target)
377 [source] = self.factory.makeGitRefs(source_repo)
378 [target] = self.factory.makeGitRefs(target_repo)
379 mp = self.factory.makeBranchMergeProposalForGit(
380 source_ref=source, target_ref=target)
381
382 # mp.syncGitHostingVirtualRefs should have been triggered by event.
383 # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
384 self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
385 self.assertEqual(1, self.hosting_fixture.deleteRef.call_count)
386 args, kwargs = self.hosting_fixture.deleteRef.calls[0]
387 self.assertEqual({'logger': None}, kwargs)
388 self.assertEqual(
389 (target_repo.getInternalPath(), "refs/merge/%s/head" % mp.id),
390 args)
391
392
258class TestBranchMergeProposalTransitions(TestCaseWithFactory):393class TestBranchMergeProposalTransitions(TestCaseWithFactory):
259 """Test the state transitions of branch merge proposals."""394 """Test the state transitions of branch merge proposals."""
260395
@@ -1184,6 +1319,10 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
1184 def test_delete_triggers_webhooks(self):1319 def test_delete_triggers_webhooks(self):
1185 # When an existing merge proposal is deleted, any relevant webhooks1320 # When an existing merge proposal is deleted, any relevant webhooks
1186 # are triggered.1321 # are triggered.
1322 self.useFixture(FeatureFixture({
1323 GIT_CREATE_MP_VIRTUAL_REF: "on",
1324 BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG: "on"}))
1325 hosting_fixture = self.useFixture(GitHostingFixture())
1187 logger = self.useFixture(FakeLogger())1326 logger = self.useFixture(FakeLogger())
1188 source = self.makeBranch()1327 source = self.makeBranch()
1189 target = self.makeBranch(same_target_as=source)1328 target = self.makeBranch(same_target_as=source)
@@ -1204,8 +1343,11 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
1204 old=MatchesDict(self.getExpectedPayload(proposal, redact=True)))1343 old=MatchesDict(self.getExpectedPayload(proposal, redact=True)))
1205 proposal.deleteProposal()1344 proposal.deleteProposal()
1206 delivery = hook.deliveries.one()1345 delivery = hook.deliveries.one()
1346 self.assertIsNotNone(delivery)
1207 self.assertCorrectDelivery(expected_payload, hook, delivery)1347 self.assertCorrectDelivery(expected_payload, hook, delivery)
1208 self.assertCorrectLogging(expected_redacted_payload, hook, logger)1348 self.assertCorrectLogging(expected_redacted_payload, hook, logger)
1349 self.assertEqual(
1350 1 if self.git else 0, hosting_fixture.deleteRef.call_count)
12091351
12101352
1211class TestGetAddress(TestCaseWithFactory):1353class TestGetAddress(TestCaseWithFactory):
@@ -1506,6 +1648,18 @@ class TestBranchMergeProposalDeletion(TestCaseWithFactory):
1506 self.assertRaises(1648 self.assertRaises(
1507 SQLObjectNotFound, BranchMergeProposalJob.get, job_id)1649 SQLObjectNotFound, BranchMergeProposalJob.get, job_id)
15081650
1651 def test_deleteProposal_for_git_removes_virtual_ref(self):
1652 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
1653 hosting_fixture = self.useFixture(GitHostingFixture())
1654 proposal = self.factory.makeBranchMergeProposalForGit()
1655 proposal.deleteProposal()
1656
1657 self.assertEqual(1, hosting_fixture.deleteRef.call_count)
1658 args = hosting_fixture.deleteRef.calls[0]
1659 self.assertEqual((
1660 (proposal.target_git_repository.getInternalPath(),
1661 'refs/merge/%s/head' % proposal.id), {'logger': None}), args)
1662
15091663
1510class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory):1664class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory):
15111665
diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
index d966dbe..04f7a8e 100644
--- a/lib/lp/code/model/tests/test_githosting.py
+++ b/lib/lp/code/model/tests/test_githosting.py
@@ -2,7 +2,7 @@
2# NOTE: The first line above must stay first; do not move the copyright2# NOTE: The first line above must stay first; do not move the copyright
3# notice to the top. See http://www.python.org/dev/peps/pep-0263/.3# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
4#4#
5# Copyright 2016-2019 Canonical Ltd. This software is licensed under the5# Copyright 2016-2020 Canonical Ltd. This software is licensed under the
6# GNU Affero General Public License version 3 (see the file LICENSE).6# GNU Affero General Public License version 3 (see the file LICENSE).
77
8"""Unit tests for `GitHostingClient`.8"""Unit tests for `GitHostingClient`.
@@ -37,12 +37,16 @@ from zope.interface import implementer
37from zope.security.proxy import removeSecurityProxy37from zope.security.proxy import removeSecurityProxy
3838
39from lp.code.errors import (39from lp.code.errors import (
40 GitReferenceDeletionFault,
40 GitRepositoryBlobNotFound,41 GitRepositoryBlobNotFound,
41 GitRepositoryCreationFault,42 GitRepositoryCreationFault,
42 GitRepositoryDeletionFault,43 GitRepositoryDeletionFault,
43 GitRepositoryScanFault,44 GitRepositoryScanFault,
45 GitTargetError,
46 NoSuchGitReference,
44 )47 )
45from lp.code.interfaces.githosting import IGitHostingClient48from lp.code.interfaces.githosting import IGitHostingClient
49from lp.code.model.githosting import RefCopyOperation
46from lp.services.job.interfaces.job import (50from lp.services.job.interfaces.job import (
47 IRunnableJob,51 IRunnableJob,
48 JobStatus,52 JobStatus,
@@ -400,6 +404,46 @@ class TestGitHostingClient(TestCase):
400 " (256 vs 0)",404 " (256 vs 0)",
401 self.client.getBlob, "123", "dir/path/file/name")405 self.client.getBlob, "123", "dir/path/file/name")
402406
407 def getCopyRefOperations(self):
408 return [
409 RefCopyOperation("1a2b3c4", "999", "refs/merge/123"),
410 RefCopyOperation("9a8b7c6", "666", "refs/merge/989"),
411 ]
412
413 def test_copyRefs(self):
414 with self.mockRequests("POST", status=202):
415 self.client.copyRefs("123", self.getCopyRefOperations())
416 self.assertRequest("repo/123/refs-copy", {
417 "operations": [
418 {
419 "from": "1a2b3c4",
420 "to": {"repo": "999", "ref": "refs/merge/123"}
421 }, {
422 "from": "9a8b7c6",
423 "to": {"repo": "666", "ref": "refs/merge/989"}
424 }
425 ]
426 }, "POST")
427
428 def test_copyRefs_refs_not_found(self):
429 with self.mockRequests("POST", status=404):
430 self.assertRaisesWithContent(
431 GitTargetError,
432 "Could not find repository 123 or one of its refs",
433 self.client.copyRefs, "123", self.getCopyRefOperations())
434
435 def test_deleteRef(self):
436 with self.mockRequests("DELETE", status=202):
437 self.client.deleteRef("123", "refs/merge/123")
438 self.assertRequest("repo/123/refs/merge/123", method="DELETE")
439
440 def test_deleteRef_refs_request_error(self):
441 with self.mockRequests("DELETE", status=500):
442 self.assertRaisesWithContent(
443 GitReferenceDeletionFault,
444 "Error deleting refs/merge/123 from repo 123: HTTP 500",
445 self.client.deleteRef, "123", "refs/merge/123")
446
403 def test_works_in_job(self):447 def test_works_in_job(self):
404 # `GitHostingClient` is usable from a running job.448 # `GitHostingClient` is usable from a running job.
405 @implementer(IRunnableJob)449 @implementer(IRunnableJob)
diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py
index 5964944..cc6ac49 100644
--- a/lib/lp/code/model/tests/test_gitjob.py
+++ b/lib/lp/code/model/tests/test_gitjob.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for `GitJob`s."""4"""Tests for `GitJob`s."""
@@ -16,6 +16,7 @@ import hashlib
16from fixtures import FakeLogger16from fixtures import FakeLogger
17from lazr.lifecycle.snapshot import Snapshot17from lazr.lifecycle.snapshot import Snapshot
18import pytz18import pytz
19from storm.store import Store
19from testtools.matchers import (20from testtools.matchers import (
20 ContainsDict,21 ContainsDict,
21 Equals,22 Equals,
@@ -24,9 +25,11 @@ from testtools.matchers import (
24 MatchesStructure,25 MatchesStructure,
25 )26 )
26import transaction27import transaction
28from zope.component import getUtility
27from zope.interface import providedBy29from zope.interface import providedBy
28from zope.security.proxy import removeSecurityProxy30from zope.security.proxy import removeSecurityProxy
2931
32from lp.app.enums import InformationType
30from lp.code.adapters.gitrepository import GitRepositoryDelta33from lp.code.adapters.gitrepository import GitRepositoryDelta
31from lp.code.enums import (34from lp.code.enums import (
32 GitGranteeType,35 GitGranteeType,
@@ -38,8 +41,10 @@ from lp.code.interfaces.branchmergeproposal import (
38from lp.code.interfaces.gitjob import (41from lp.code.interfaces.gitjob import (
39 IGitJob,42 IGitJob,
40 IGitRefScanJob,43 IGitRefScanJob,
44 IGitRepositoryVirtualRefsSyncJobSource,
41 IReclaimGitRepositorySpaceJob,45 IReclaimGitRepositorySpaceJob,
42 )46 )
47from lp.code.interfaces.gitrepository import GIT_CREATE_MP_VIRTUAL_REF
43from lp.code.model.gitjob import (48from lp.code.model.gitjob import (
44 describe_repository_delta,49 describe_repository_delta,
45 GitJob,50 GitJob,
@@ -484,5 +489,35 @@ class TestDescribeRepositoryDelta(TestCaseWithFactory):
484 snapshot, repository)489 snapshot, repository)
485490
486491
492class TestGitRepositoryVirtualRefsSyncJob(TestCaseWithFactory):
493 """Tests for `GitRepositoryVirtualRefsSyncJob`."""
494
495 layer = ZopelessDatabaseLayer
496
497 def test_changing_repo_to_private_deletes_refs(self):
498 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
499 hosting_fixture = self.useFixture(GitHostingFixture())
500 mp = self.factory.makeBranchMergeProposalForGit()
501 source_repo = mp.source_git_repository
502 target_repo = mp.target_git_repository
503 source_repo.transitionToInformationType(
504 InformationType.PRIVATESECURITY, source_repo.owner, False)
505 Store.of(source_repo).flush()
506
507 hosting_fixture.copyRefs.resetCalls()
508 hosting_fixture.deleteRef.resetCalls()
509 with dbuser("branchscanner"):
510 job_set = JobRunner.fromReady(
511 getUtility(IGitRepositoryVirtualRefsSyncJobSource))
512 job_set.runAll()
513 self.assertEqual(1, len(job_set.completed_jobs))
514
515 self.assertEqual(0, hosting_fixture.copyRefs.call_count)
516 self.assertEqual(1, hosting_fixture.deleteRef.call_count)
517 args, kwargs = hosting_fixture.deleteRef.calls[0]
518 self.assertEqual(
519 (target_repo.getInternalPath(), 'refs/merge/%s/head' % mp.id),
520 args)
521
487# XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too,522# XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too,
488# but that isn't feasible until we have a proper turnip fixture.523# but that isn't feasible until we have a proper turnip fixture.
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 62bf865..6a9fa20 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -18,6 +18,7 @@ from datetime import (
18import email18import email
19from functools import partial19from functools import partial
20import hashlib20import hashlib
21import itertools
21import json22import json
2223
23from breezy import urlutils24from breezy import urlutils
@@ -89,6 +90,7 @@ from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
89from lp.code.interfaces.gitjob import (90from lp.code.interfaces.gitjob import (
90 IGitRefScanJobSource,91 IGitRefScanJobSource,
91 IGitRepositoryModifiedMailJobSource,92 IGitRepositoryModifiedMailJobSource,
93 IGitRepositoryVirtualRefsSyncJobSource,
92 )94 )
93from lp.code.interfaces.gitlookup import IGitLookup95from lp.code.interfaces.gitlookup import IGitLookup
94from lp.code.interfaces.gitnamespace import (96from lp.code.interfaces.gitnamespace import (
@@ -96,6 +98,7 @@ from lp.code.interfaces.gitnamespace import (
96 IGitNamespaceSet,98 IGitNamespaceSet,
97 )99 )
98from lp.code.interfaces.gitrepository import (100from lp.code.interfaces.gitrepository import (
101 GIT_CREATE_MP_VIRTUAL_REF,
99 IGitRepository,102 IGitRepository,
100 IGitRepositorySet,103 IGitRepositorySet,
101 IGitRepositoryView,104 IGitRepositoryView,
@@ -113,6 +116,7 @@ from lp.code.model.branchmergeproposaljob import (
113 )116 )
114from lp.code.model.codereviewcomment import CodeReviewComment117from lp.code.model.codereviewcomment import CodeReviewComment
115from lp.code.model.gitactivity import GitActivity118from lp.code.model.gitactivity import GitActivity
119from lp.code.model.githosting import RefCopyOperation
116from lp.code.model.gitjob import (120from lp.code.model.gitjob import (
117 GitJob,121 GitJob,
118 GitJobType,122 GitJobType,
@@ -146,6 +150,7 @@ from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
146from lp.registry.interfaces.personproduct import IPersonProductFactory150from lp.registry.interfaces.personproduct import IPersonProductFactory
147from lp.registry.tests.test_accesspolicy import get_policies_for_artifact151from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
148from lp.services.authserver.xmlrpc import AuthServerAPIView152from lp.services.authserver.xmlrpc import AuthServerAPIView
153from lp.services.compat import mock
149from lp.services.config import config154from lp.services.config import config
150from lp.services.database.constants import UTC_NOW155from lp.services.database.constants import UTC_NOW
151from lp.services.database.interfaces import IStore156from lp.services.database.interfaces import IStore
@@ -180,6 +185,7 @@ from lp.testing import (
180 verifyObject,185 verifyObject,
181 )186 )
182from lp.testing.dbuser import dbuser187from lp.testing.dbuser import dbuser
188from lp.testing.fixture import ZopeUtilityFixture
183from lp.testing.layers import (189from lp.testing.layers import (
184 DatabaseFunctionalLayer,190 DatabaseFunctionalLayer,
185 LaunchpadFunctionalLayer,191 LaunchpadFunctionalLayer,
@@ -782,6 +788,7 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
782 # Make sure that the tests all flush the database changes.788 # Make sure that the tests all flush the database changes.
783 self.addCleanup(Store.of(self.repository).flush)789 self.addCleanup(Store.of(self.repository).flush)
784 login_person(self.user)790 login_person(self.user)
791 self.hosting_fixture = self.useFixture(GitHostingFixture())
785792
786 def test_deletable(self):793 def test_deletable(self):
787 # A newly created repository can be deleted without any problems.794 # A newly created repository can be deleted without any problems.
@@ -823,7 +830,6 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
823830
824 def test_code_import_does_not_disable_deletion(self):831 def test_code_import_does_not_disable_deletion(self):
825 # A repository that has an attached code import can be deleted.832 # A repository that has an attached code import can be deleted.
826 self.useFixture(GitHostingFixture())
827 code_import = self.factory.makeCodeImport(833 code_import = self.factory.makeCodeImport(
828 target_rcs_type=TargetRevisionControlSystems.GIT)834 target_rcs_type=TargetRevisionControlSystems.GIT)
829 repository = code_import.git_repository835 repository = code_import.git_repository
@@ -983,6 +989,7 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
983 # unsubscribe the repository owner here.989 # unsubscribe the repository owner here.
984 self.repository.unsubscribe(990 self.repository.unsubscribe(
985 self.repository.owner, self.repository.owner)991 self.repository.owner, self.repository.owner)
992 self.hosting_fixture = self.useFixture(GitHostingFixture())
986993
987 def test_plain_repository(self):994 def test_plain_repository(self):
988 # A fresh repository has no deletion requirements.995 # A fresh repository has no deletion requirements.
@@ -1114,7 +1121,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
11141121
1115 def test_code_import_requirements(self):1122 def test_code_import_requirements(self):
1116 # Code imports are not included explicitly in deletion requirements.1123 # Code imports are not included explicitly in deletion requirements.
1117 self.useFixture(GitHostingFixture())
1118 code_import = self.factory.makeCodeImport(1124 code_import = self.factory.makeCodeImport(
1119 target_rcs_type=TargetRevisionControlSystems.GIT)1125 target_rcs_type=TargetRevisionControlSystems.GIT)
1120 # Remove the implicit repository subscription first.1126 # Remove the implicit repository subscription first.
@@ -1125,7 +1131,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
11251131
1126 def test_code_import_deletion(self):1132 def test_code_import_deletion(self):
1127 # break_references allows deleting a code import repository.1133 # break_references allows deleting a code import repository.
1128 self.useFixture(GitHostingFixture())
1129 code_import = self.factory.makeCodeImport(1134 code_import = self.factory.makeCodeImport(
1130 target_rcs_type=TargetRevisionControlSystems.GIT)1135 target_rcs_type=TargetRevisionControlSystems.GIT)
1131 code_import_id = code_import.id1136 code_import_id = code_import.id
@@ -1181,7 +1186,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
11811186
1182 def test_DeleteCodeImport(self):1187 def test_DeleteCodeImport(self):
1183 # DeleteCodeImport.__call__ must delete the CodeImport.1188 # DeleteCodeImport.__call__ must delete the CodeImport.
1184 self.useFixture(GitHostingFixture())
1185 code_import = self.factory.makeCodeImport(1189 code_import = self.factory.makeCodeImport(
1186 target_rcs_type=TargetRevisionControlSystems.GIT)1190 target_rcs_type=TargetRevisionControlSystems.GIT)
1187 code_import_id = code_import.id1191 code_import_id = code_import.id
@@ -1520,6 +1524,10 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
15201524
1521 layer = DatabaseFunctionalLayer1525 layer = DatabaseFunctionalLayer
15221526
1527 def setUp(self):
1528 super(TestGitRepositoryRefs, self).setUp()
1529 self.hosting_fixture = self.useFixture(GitHostingFixture())
1530
1523 def test__convertRefInfo(self):1531 def test__convertRefInfo(self):
1524 # _convertRefInfo converts a valid info dictionary.1532 # _convertRefInfo converts a valid info dictionary.
1525 sha1 = unicode(hashlib.sha1("").hexdigest())1533 sha1 = unicode(hashlib.sha1("").hexdigest())
@@ -1788,18 +1796,17 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1788 # planRefChanges excludes some ref prefixes by default, and can be1796 # planRefChanges excludes some ref prefixes by default, and can be
1789 # configured otherwise.1797 # configured otherwise.
1790 repository = self.factory.makeGitRepository()1798 repository = self.factory.makeGitRepository()
1791 hosting_fixture = self.useFixture(GitHostingFixture())
1792 repository.planRefChanges("dummy")1799 repository.planRefChanges("dummy")
1793 self.assertEqual(1800 self.assertEqual(
1794 [{"exclude_prefixes": ["refs/changes/"]}],1801 [{"exclude_prefixes": ["refs/changes/"]}],
1795 hosting_fixture.getRefs.extract_kwargs())1802 self.hosting_fixture.getRefs.extract_kwargs())
1796 hosting_fixture.getRefs.calls = []1803 self.hosting_fixture.getRefs.calls = []
1797 self.pushConfig(1804 self.pushConfig(
1798 "codehosting", git_exclude_ref_prefixes="refs/changes/ refs/pull/")1805 "codehosting", git_exclude_ref_prefixes="refs/changes/ refs/pull/")
1799 repository.planRefChanges("dummy")1806 repository.planRefChanges("dummy")
1800 self.assertEqual(1807 self.assertEqual(
1801 [{"exclude_prefixes": ["refs/changes/", "refs/pull/"]}],1808 [{"exclude_prefixes": ["refs/changes/", "refs/pull/"]}],
1802 hosting_fixture.getRefs.extract_kwargs())1809 self.hosting_fixture.getRefs.extract_kwargs())
18031810
1804 def test_fetchRefCommits(self):1811 def test_fetchRefCommits(self):
1805 # fetchRefCommits fetches detailed tip commit metadata for the1812 # fetchRefCommits fetches detailed tip commit metadata for the
@@ -1871,9 +1878,8 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1871 def test_fetchRefCommits_empty(self):1878 def test_fetchRefCommits_empty(self):
1872 # If given an empty refs dictionary, fetchRefCommits returns early1879 # If given an empty refs dictionary, fetchRefCommits returns early
1873 # without contacting the hosting service.1880 # without contacting the hosting service.
1874 hosting_fixture = self.useFixture(GitHostingFixture())
1875 GitRepository.fetchRefCommits("dummy", {})1881 GitRepository.fetchRefCommits("dummy", {})
1876 self.assertEqual([], hosting_fixture.getCommits.calls)1882 self.assertEqual([], self.hosting_fixture.getCommits.calls)
18771883
1878 def test_synchroniseRefs(self):1884 def test_synchroniseRefs(self):
1879 # synchroniseRefs copes with synchronising a repository where some1885 # synchroniseRefs copes with synchronising a repository where some
@@ -1916,7 +1922,6 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1916 self.assertThat(repository.refs, MatchesSetwise(*matchers))1922 self.assertThat(repository.refs, MatchesSetwise(*matchers))
19171923
1918 def test_set_default_branch(self):1924 def test_set_default_branch(self):
1919 hosting_fixture = self.useFixture(GitHostingFixture())
1920 repository = self.factory.makeGitRepository()1925 repository = self.factory.makeGitRepository()
1921 self.factory.makeGitRefs(1926 self.factory.makeGitRefs(
1922 repository=repository,1927 repository=repository,
@@ -1927,22 +1932,20 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1927 self.assertEqual(1932 self.assertEqual(
1928 [((repository.getInternalPath(),),1933 [((repository.getInternalPath(),),
1929 {"default_branch": "refs/heads/new"})],1934 {"default_branch": "refs/heads/new"})],
1930 hosting_fixture.setProperties.calls)1935 self.hosting_fixture.setProperties.calls)
1931 self.assertEqual("refs/heads/new", repository.default_branch)1936 self.assertEqual("refs/heads/new", repository.default_branch)
19321937
1933 def test_set_default_branch_unchanged(self):1938 def test_set_default_branch_unchanged(self):
1934 hosting_fixture = self.useFixture(GitHostingFixture())
1935 repository = self.factory.makeGitRepository()1939 repository = self.factory.makeGitRepository()
1936 self.factory.makeGitRefs(1940 self.factory.makeGitRefs(
1937 repository=repository, paths=["refs/heads/master"])1941 repository=repository, paths=["refs/heads/master"])
1938 removeSecurityProxy(repository)._default_branch = "refs/heads/master"1942 removeSecurityProxy(repository)._default_branch = "refs/heads/master"
1939 with person_logged_in(repository.owner):1943 with person_logged_in(repository.owner):
1940 repository.default_branch = "master"1944 repository.default_branch = "master"
1941 self.assertEqual([], hosting_fixture.setProperties.calls)1945 self.assertEqual([], self.hosting_fixture.setProperties.calls)
1942 self.assertEqual("refs/heads/master", repository.default_branch)1946 self.assertEqual("refs/heads/master", repository.default_branch)
19431947
1944 def test_set_default_branch_imported(self):1948 def test_set_default_branch_imported(self):
1945 hosting_fixture = self.useFixture(GitHostingFixture())
1946 repository = self.factory.makeGitRepository(1949 repository = self.factory.makeGitRepository(
1947 repository_type=GitRepositoryType.IMPORTED)1950 repository_type=GitRepositoryType.IMPORTED)
1948 self.factory.makeGitRefs(1951 self.factory.makeGitRefs(
@@ -1955,7 +1958,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1955 "Cannot modify non-hosted Git repository %s." %1958 "Cannot modify non-hosted Git repository %s." %
1956 repository.display_name,1959 repository.display_name,
1957 setattr, repository, "default_branch", "new")1960 setattr, repository, "default_branch", "new")
1958 self.assertEqual([], hosting_fixture.setProperties.calls)1961 self.assertEqual([], self.hosting_fixture.setProperties.calls)
1959 self.assertEqual("refs/heads/master", repository.default_branch)1962 self.assertEqual("refs/heads/master", repository.default_branch)
19601963
1961 def test_exception_unset_default_branch(self):1964 def test_exception_unset_default_branch(self):
@@ -2576,18 +2579,59 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory):
25762579
2577 layer = DatabaseFunctionalLayer2580 layer = DatabaseFunctionalLayer
25782581
2579 def test_schedules_diff_updates(self):2582 def setUp(self):
2583 super(TestGitRepositoryUpdateLandingTargets, self).setUp()
2584 self.hosting_fixture = self.useFixture(GitHostingFixture())
2585
2586 def assertSchedulesDiffUpdate(self, with_mp_virtual_ref):
2580 """Create jobs for all merge proposals."""2587 """Create jobs for all merge proposals."""
2581 bmp1 = self.factory.makeBranchMergeProposalForGit()2588 bmp1 = self.factory.makeBranchMergeProposalForGit()
2582 bmp2 = self.factory.makeBranchMergeProposalForGit(2589 bmp2 = self.factory.makeBranchMergeProposalForGit(
2583 source_ref=bmp1.source_git_ref)2590 source_ref=bmp1.source_git_ref)
2584 jobs = bmp1.source_git_repository.updateLandingTargets(2591
2592 # Only enable this virtual ref here, since
2593 # self.factory.makeBranchMergeProposalForGit also tries to create
2594 # the virtual refs.
2595 if with_mp_virtual_ref:
2596 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
2597 else:
2598 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: ""}))
2599 jobs, ref_copies = bmp1.source_git_repository.updateLandingTargets(
2585 [bmp1.source_git_path])2600 [bmp1.source_git_path])
2586 self.assertEqual(2, len(jobs))2601 self.assertEqual(2, len(jobs))
2587 bmps_to_update = [2602 bmps_to_update = [
2588 removeSecurityProxy(job).branch_merge_proposal for job in jobs]2603 removeSecurityProxy(job).branch_merge_proposal for job in jobs]
2589 self.assertContentEqual([bmp1, bmp2], bmps_to_update)2604 self.assertContentEqual([bmp1, bmp2], bmps_to_update)
25902605
2606 if not with_mp_virtual_ref:
2607 self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
2608 else:
2609 self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
2610 args, kwargs = self.hosting_fixture.copyRefs.calls[0]
2611 self.assertEqual({}, kwargs)
2612 self.assertEqual(2, len(args))
2613 path, operations = args
2614 self.assertEqual(
2615 path, bmp1.source_git_repository.getInternalPath())
2616 self.assertThat(operations[0], MatchesStructure(
2617 source_ref=Equals(bmp1.source_git_commit_sha1),
2618 target_repo=Equals(
2619 bmp1.target_git_repository.getInternalPath()),
2620 target_ref=Equals("refs/merge/%s/head" % bmp1.id),
2621 ))
2622 self.assertThat(operations[1], MatchesStructure(
2623 source_ref=Equals(bmp2.source_git_commit_sha1),
2624 target_repo=Equals(
2625 bmp2.target_git_repository.getInternalPath()),
2626 target_ref=Equals("refs/merge/%s/head" % bmp2.id),
2627 ))
2628
2629 def test_schedules_diff_updates_with_mp_virtual_ref(self):
2630 self.assertSchedulesDiffUpdate(True)
2631
2632 def test_schedules_diff_updates_without_mp_virtual_ref(self):
2633 self.assertSchedulesDiffUpdate(False)
2634
2591 def test_ignores_final(self):2635 def test_ignores_final(self):
2592 """Diffs for proposals in final states aren't updated."""2636 """Diffs for proposals in final states aren't updated."""
2593 [source_ref] = self.factory.makeGitRefs()2637 [source_ref] = self.factory.makeGitRefs()
@@ -2599,8 +2643,15 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory):
2599 for bmp in source_ref.landing_targets:2643 for bmp in source_ref.landing_targets:
2600 if bmp.queue_status not in FINAL_STATES:2644 if bmp.queue_status not in FINAL_STATES:
2601 removeSecurityProxy(bmp).deleteProposal()2645 removeSecurityProxy(bmp).deleteProposal()
2602 jobs = source_ref.repository.updateLandingTargets([source_ref.path])2646
2647 # Enable the feature here, since factory.makeBranchMergeProposalForGit
2648 # would also trigger the copy refs call.
2649 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
2650 jobs, ref_copies = source_ref.repository.updateLandingTargets(
2651 [source_ref.path])
2603 self.assertEqual(0, len(jobs))2652 self.assertEqual(0, len(jobs))
2653 self.assertEqual(0, len(ref_copies))
2654 self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
26042655
26052656
2606class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):2657class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
@@ -4616,3 +4667,61 @@ class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
4616 ["Caveat check for '%s' failed." %4667 ["Caveat check for '%s' failed." %
4617 find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id],4668 find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id],
4618 issuer, macaroon2, repository, user=repository.owner)4669 issuer, macaroon2, repository, user=repository.owner)
4670
4671
4672class TestGitRepositoryPrivacyChangeSyncVirtualRefs(TestCaseWithFactory):
4673 layer = DatabaseFunctionalLayer
4674
4675 def assertChangePrivacyTriggersSync(
4676 self, from_list, to_list, should_trigger_sync=True):
4677 """Runs repository.transitionToInformationType from every item in
4678 `from_list` to each item in `to_list`, and checks if the virtual
4679 refs sync was triggered or not, depending on `should_trigger_sync`."""
4680 sync_job = mock.Mock()
4681 self.useFixture(ZopeUtilityFixture(
4682 sync_job, IGitRepositoryVirtualRefsSyncJobSource))
4683
4684 admin = self.factory.makeAdministrator()
4685 login_person(admin)
4686 for from_type, to_type in itertools.product(from_list, to_list):
4687 if from_type == to_type:
4688 continue
4689 repository = self.factory.makeGitRepository()
4690 naked_repo = removeSecurityProxy(repository)
4691 naked_repo.information_type = from_type
4692 # Skip access policy reconciliation.
4693 naked_repo._reconcileAccess = mock.Mock()
4694 naked_repo.transitionToInformationType(to_type, admin, False)
4695
4696 if should_trigger_sync:
4697 sync_job.create.assert_called_with(repository)
4698 else:
4699 self.assertEqual(
4700 0, sync_job.create.call_count,
4701 "Changing from %s to %s should't trigger vrefs sync"
4702 % (from_type, to_type))
4703 sync_job.reset_mock()
4704
4705 def test_setting_repo_public_triggers_ref_sync_job(self):
4706 self.assertChangePrivacyTriggersSync(
4707 PRIVATE_INFORMATION_TYPES,
4708 PUBLIC_INFORMATION_TYPES,
4709 should_trigger_sync=True)
4710
4711 def test_setting_repo_private_triggers_ref_sync_job(self):
4712 self.assertChangePrivacyTriggersSync(
4713 PUBLIC_INFORMATION_TYPES,
4714 PRIVATE_INFORMATION_TYPES,
4715 should_trigger_sync=True)
4716
4717 def test_keeping_repo_private_dont_trigger_ref_sync_job(self):
4718 self.assertChangePrivacyTriggersSync(
4719 PRIVATE_INFORMATION_TYPES,
4720 PRIVATE_INFORMATION_TYPES,
4721 should_trigger_sync=False)
4722
4723 def test_keeping_repo_public_dont_trigger_ref_sync_job(self):
4724 self.assertChangePrivacyTriggersSync(
4725 PUBLIC_INFORMATION_TYPES,
4726 PUBLIC_INFORMATION_TYPES,
4727 should_trigger_sync=False)
diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py
index a214c8f..b394388 100644
--- a/lib/lp/code/subscribers/branchmergeproposal.py
+++ b/lib/lp/code/subscribers/branchmergeproposal.py
@@ -1,4 +1,4 @@
1# Copyright 2010-2016 Canonical Ltd. This software is licensed under the1# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Event subscribers for branch merge proposals."""4"""Event subscribers for branch merge proposals."""
@@ -63,9 +63,13 @@ def _trigger_webhook(merge_proposal, payload):
63def merge_proposal_created(merge_proposal, event):63def merge_proposal_created(merge_proposal, event):
64 """A new merge proposal has been created.64 """A new merge proposal has been created.
6565
66 Create a job to update the diff for the merge proposal; trigger webhooks.66 Create a job to update the diff for the merge proposal; trigger webhooks
67 and copy virtual refs as needed.
67 """68 """
68 getUtility(IUpdatePreviewDiffJobSource).create(merge_proposal)69 getUtility(IUpdatePreviewDiffJobSource).create(merge_proposal)
70
71 merge_proposal.syncGitHostingVirtualRefs()
72
69 if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):73 if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):
70 payload = {74 payload = {
71 "action": "created",75 "action": "created",
diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py
index 0e784f0..90a7a87 100644
--- a/lib/lp/code/tests/helpers.py
+++ b/lib/lp/code/tests/helpers.py
@@ -368,6 +368,8 @@ class GitHostingFixture(fixtures.Fixture):
368 self.getBlob = FakeMethod(result=blob)368 self.getBlob = FakeMethod(result=blob)
369 self.delete = FakeMethod()369 self.delete = FakeMethod()
370 self.disable_memcache = disable_memcache370 self.disable_memcache = disable_memcache
371 self.copyRefs = FakeMethod()
372 self.deleteRef = FakeMethod()
371373
372 def _setUp(self):374 def _setUp(self):
373 self.useFixture(ZopeUtilityFixture(self, IGitHostingClient))375 self.useFixture(ZopeUtilityFixture(self, IGitHostingClient))
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index bb8afcd..3921da2 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1828,6 +1828,10 @@ module: lp.code.interfaces.gitjob
1828dbuser: send-branch-mail1828dbuser: send-branch-mail
1829crontab_group: MAIN1829crontab_group: MAIN
18301830
1831[IGitRepositoryVirtualRefsSyncJobSource]
1832module: lp.code.interfaces.gitjob
1833dbuser: branchscanner
1834
1831[IInitializeDistroSeriesJobSource]1835[IInitializeDistroSeriesJobSource]
1832module: lp.soyuz.interfaces.distributionjob1836module: lp.soyuz.interfaces.distributionjob
1833dbuser: initializedistroseries1837dbuser: initializedistroseries
diff --git a/lib/lp/testing/fakemethod.py b/lib/lp/testing/fakemethod.py
index 4bba8d3..b4895ce 100644
--- a/lib/lp/testing/fakemethod.py
+++ b/lib/lp/testing/fakemethod.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2011 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -57,3 +57,6 @@ class FakeMethod:
57 def extract_kwargs(self):57 def extract_kwargs(self):
58 """Return just the calls' keyword-arguments dicts."""58 """Return just the calls' keyword-arguments dicts."""
59 return [kwargs for args, kwargs in self.calls]59 return [kwargs for args, kwargs in self.calls]
60
61 def resetCalls(self):
62 self.calls = []