Merge ~pappacena/launchpad:create-mp-refs into launchpad:master

Proposed by Thiago F. Pappacena
Status: Needs review
Proposed branch: ~pappacena/launchpad:create-mp-refs
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:githosting-copy-and-delete-refs
Diff against target: 745 lines (+365/-27)
10 files modified
lib/lp/code/interfaces/branchmergeproposal.py (+26/-1)
lib/lp/code/interfaces/gitrepository.py (+12/-1)
lib/lp/code/model/branchmergeproposal.py (+92/-0)
lib/lp/code/model/githosting.py (+0/-1)
lib/lp/code/model/gitrepository.py (+5/-2)
lib/lp/code/model/tests/test_branchmergeproposal.py (+155/-0)
lib/lp/code/model/tests/test_githosting.py (+0/-1)
lib/lp/code/model/tests/test_gitrepository.py (+65/-19)
lib/lp/code/subscribers/branchmergeproposal.py (+8/-2)
lib/lp/code/tests/helpers.py (+2/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+390581@code.launchpad.net

Commit message

Creating and deleting merge proposal virtual ref for "merge/xxx/head" on git hosting when appropriated.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

How does this interact with privacy?

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

wgrant, good point.

When a user opens a MP#1 targeting RepositoryX's master, for example, RepositoryX itself will have a read-only ref called `refs/merge/1/head`. The idea is that whomever is responsible for RepositoryX will have an easier way to pull locally the changes introduced by MP#1.

Let's assume a RepositoryX is private. In theory, nothing changes for the user opening the MP#1: the privacy checks and requirements to actually open a new MP targeting RepositoryX are still the same.

The only extra security check introduced on RepositoryX will be on Turnip side, to block pushes to `refs/merge/...` namespace: https://code.launchpad.net/~pappacena/turnip/+git/turnip/+merge/390620.

Do you see any specific privacy problem with this scenario?

Revision history for this message
William Grant (wgrant) wrote :

On 12/9/20 12:38 am, Thiago F. Pappacena wrote:
> wgrant, good point.
>
> When a user opens a MP#1 targeting RepositoryX's master, for example, RepositoryX itself will have a read-only ref called `refs/merge/1/head`. The idea is that whomever is responsible for RepositoryX will have an easier way to pull locally the changes introduced by MP#1.
>
> Let's assume a RepositoryX is private. In theory, nothing changes for the user opening the MP#1: the privacy checks and requirements to actually open a new MP targeting RepositoryX are still the same.
>
> The only extra security check introduced on RepositoryX will be on Turnip side, to block pushes to `refs/merge/...` namespace: https://code.launchpad.net/~pappacena/turnip/+git/turnip/+merge/390620.
>
> Do you see any specific privacy problem with this scenario?

The problem arises when the *source* repository is private. Consider,
for example, a security fix MP: a user can only view an MP if they can
see both the source and target branches. But this will let anyone who
can see the target repository examine the code in the MP from a
potentially invisible private branch.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

wgrant, got it.

I guess we have 2 options in this case:

1- We don't add at all the the `refs/merge/<xxx>` if the source repository is private; or
2- We do extra permission checks on refs on Turnip on every operation, and not only pushes as it seems to be implemented today.

The first option brings some complexity in terms of changing a repository to/from private after the virtual ref is already created, but is simple to implement.

I prefer the second option, as it seems cleaner and better for the long term IMHO, although it might require (a small) extra implementation effort now.

Any thoughts on this?

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Just to keep the record, it turn out that filtering git fetches is not that trivial, since there is no hook for that anymore (http://git.661346.n2.nabble.com/Removal-of-post-upload-hook-td4394312.html).

We will handle private repositories the following way:

1- When opening the MP, if the source is a different private repository, the virtual ref will not be created;
2- When opening the MP, if the source is the same repository (regardless of being private or not), we will create the virtual ref;
3- When opening the MP, if the owner of the source repository is the same as the owner of the target repository, we will creat ethe virtual ref
4- When privacy or the owner of a repository changes, we need to run a job to check again the above conditions and make sure that the virtual ref is created/deleted accordingly

I'll split in some MPs just to make the review easier (this MP is already a bit too big), but I guess all of these rules should be landed at once.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

The MP with the job that should re-sync the virtual refs is here: https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/390940.

As said before, they are splitted just to make the review process easier, but the functionality should only be landed once both MPs are ready to be landed together.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Colin Watson (cjwatson) wrote :

Also please set a commit message.

~pappacena/launchpad:create-mp-refs updated
4fae2e0... by Thiago F. Pappacena

Merge branch 'master' into create-mp-refs

4740bf9... by Thiago F. Pappacena

Methods renaming and better defining interfaces

297a039... by Thiago F. Pappacena

Refactoring

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Strange that the commit message is empty. I'm adding it now.

Pushed the requested changes. There are a few comments that may worth checking, cjwatson.

~pappacena/launchpad:create-mp-refs updated
b4af659... by Thiago F. Pappacena

Merge branch 'master' into create-mp-refs

Unmerged commits

b4af659... by Thiago F. Pappacena

Merge branch 'master' into create-mp-refs

297a039... by Thiago F. Pappacena

Refactoring

4740bf9... by Thiago F. Pappacena

Methods renaming and better defining interfaces

4fae2e0... by Thiago F. Pappacena

Merge branch 'master' into create-mp-refs

f3bda00... by Thiago F. Pappacena

Fixing test

30c88c7... by Thiago F. Pappacena

Merge branch 'githosting-copy-and-delete-refs' into create-mp-refs

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/interfaces/branchmergeproposal.py b/lib/lp/code/interfaces/branchmergeproposal.py
index bf481c9..449e921 100644
--- a/lib/lp/code/interfaces/branchmergeproposal.py
+++ b/lib/lp/code/interfaces/branchmergeproposal.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2018 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"""The interface for branch merge proposals."""4"""The interface for branch merge proposals."""
@@ -519,6 +519,31 @@ class IBranchMergeProposalView(Interface):
519 def getLatestDiffUpdateJob():519 def getLatestDiffUpdateJob():
520 """Return the latest IUpdatePreviewDiffJob for this MP."""520 """Return the latest IUpdatePreviewDiffJob for this MP."""
521521
522 def getGitRefCopyOperations():
523 """Gets the list of GitHosting copy operations that should be done
524 in order to keep this MP's virtual refs up-to-date.
525
526 :return: A list of RefCopyOperation objects.
527 """
528
529 def syncGitVirtualRefs(force_delete=False):
530 """Requests all copies and deletion of virtual refs to make git code
531 hosting in sync with this MP.
532
533 :param force_delete: Do not try to copy any new virtual ref. Just
534 delete all virtual refs possibly created.
535 """
536
537 def bulkSyncGitVirtualRefs(git_repository, merge_proposals):
538 """Synchronizes a set of merge proposals' virtual refs.
539
540 :params git_repository: The source git repository of the given merge
541 proposals.
542 :params merge_proposals: The set of merge proposals that should have
543 the virtual refs synced.
544 :return: A list of copy operations executed.
545 """
546
522547
523class IBranchMergeProposalEdit(Interface):548class IBranchMergeProposalEdit(Interface):
524549
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 192133a..48e7dd3 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 list of ref copies 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 061fb1e..ec65e98 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
@@ -84,7 +85,12 @@ from lp.code.interfaces.codereviewcomment import ICodeReviewComment
84from lp.code.interfaces.codereviewinlinecomment import (85from lp.code.interfaces.codereviewinlinecomment import (
85 ICodeReviewInlineCommentSet,86 ICodeReviewInlineCommentSet,
86 )87 )
88from lp.code.interfaces.githosting import IGitHostingClient
87from lp.code.interfaces.gitref import IGitRef89from lp.code.interfaces.gitref import IGitRef
90from lp.code.interfaces.gitrepository import (
91 GIT_CREATE_MP_VIRTUAL_REF,
92 GIT_MP_VIRTUAL_REF_FORMAT,
93 )
88from lp.code.mail.branch import RecipientReason94from lp.code.mail.branch import RecipientReason
89from lp.code.model.branchrevision import BranchRevision95from lp.code.model.branchrevision import BranchRevision
90from lp.code.model.codereviewcomment import CodeReviewComment96from lp.code.model.codereviewcomment import CodeReviewComment
@@ -94,6 +100,7 @@ from lp.code.model.diff import (
94 IncrementalDiff,100 IncrementalDiff,
95 PreviewDiff,101 PreviewDiff,
96 )102 )
103from lp.code.model.githosting import RefCopyOperation
97from lp.registry.interfaces.person import (104from lp.registry.interfaces.person import (
98 IPerson,105 IPerson,
99 IPersonSet,106 IPersonSet,
@@ -123,6 +130,7 @@ from lp.services.database.sqlbase import (
123 quote,130 quote,
124 SQLBase,131 SQLBase,
125 )132 )
133from lp.services.features import getFeatureFlag
126from lp.services.helpers import shortlist134from lp.services.helpers import shortlist
127from lp.services.job.interfaces.job import JobStatus135from lp.services.job.interfaces.job import JobStatus
128from lp.services.job.model.job import Job136from lp.services.job.model.job import Job
@@ -144,6 +152,9 @@ from lp.soyuz.enums import (
144 )152 )
145153
146154
155logger = logging.getLogger(__name__)
156
157
147def is_valid_transition(proposal, from_state, next_state, user=None):158def is_valid_transition(proposal, from_state, next_state, user=None):
148 """Is it valid for this user to move this proposal to to next_state?159 """Is it valid for this user to move this proposal to to next_state?
149160
@@ -971,6 +982,7 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
971 branch_merge_proposal=self.id):982 branch_merge_proposal=self.id):
972 job.destroySelf()983 job.destroySelf()
973 self._preview_diffs.remove()984 self._preview_diffs.remove()
985
974 self.destroySelf()986 self.destroySelf()
975987
976 def getUnlandedSourceBranchRevisions(self):988 def getUnlandedSourceBranchRevisions(self):
@@ -1217,6 +1229,86 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
1217 for diff in diffs)1229 for diff in diffs)
1218 return [diff_dict.get(revisions) for revisions in revision_list]1230 return [diff_dict.get(revisions) for revisions in revision_list]
12191231
1232 def getGitRefCopyOperations(self):
1233 """See `IBranchMergeProposal`"""
1234 if (not self.target_git_repository
1235 or not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF)):
1236 return []
1237 # If the source repository is private and it's opening a MP to
1238 # another repository, we should not risk disclosing the private code to
1239 # everyone with permission to see the target repo:
1240 private_source = self.source_git_repository.private
1241 same_owner = self.source_git_repository.owner.inTeam(
1242 self.target_git_repository.owner)
1243 if private_source and not 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 _copyGitVirtualRefs(self):
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 copied refs.
1255 """
1256 copy_operations = self.getGitRefCopyOperations()
1257 if copy_operations:
1258 hosting_client = getUtility(IGitHostingClient)
1259 hosting_client.copyRefs(
1260 self.source_git_repository.getInternalPath(),
1261 copy_operations)
1262 return [i.target_ref for i in copy_operations]
1263
1264 def _deleteGitVirtualRefs(self, except_refs=None):
1265 """Deletes on git code hosting service all virtual refs, except
1266 those ones in the given list.
1267
1268 For now, the only existing virtual ref to be deleted is
1269 GIT_CREATE_MP_VIRTUAL_REF. So this method will only ever delete at
1270 most 1 virtual ref. Once new virtual refs will be created, this method
1271 should be extended to delete them also.
1272 """
1273 if self.source_git_ref is None:
1274 return
1275 if not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF):
1276 # XXX pappacena 2020-09-15: Do not try to remove virtual refs if
1277 # the feature is disabled. It might be disabled due to bug on
1278 # the first versions, for example, and we should have a way to
1279 # skip this feature entirely.
1280 # Once the feature is stable, we should actually *always* delete
1281 # the virtual refs, since we don't know if the feature was
1282 # enabled or not when the branch was created.
1283 return
1284 ref = GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self)
1285 if ref not in (except_refs or []):
1286 hosting_client = getUtility(IGitHostingClient)
1287 hosting_client.deleteRefs(
1288 [(self.target_git_repository.getInternalPath(), ref)])
1289
1290 def syncGitVirtualRefs(self, force_delete=False):
1291 """See `IBranchMergeProposal`"""
1292 if force_delete:
1293 # When force deletion of virtual refs, do not try to copy
1294 # anything. Just run the delete step.
1295 copied_refs = []
1296 else:
1297 copied_refs = self._copyGitVirtualRefs()
1298 self._deleteGitVirtualRefs(except_refs=copied_refs)
1299
1300 @classmethod
1301 def bulkSyncGitVirtualRefs(cls, git_repository, merge_proposals):
1302 """See `IBranchMergeProposal`"""
1303 copy_operations = []
1304 for merge_proposal in merge_proposals:
1305 copy_operations += merge_proposal.getGitRefCopyOperations()
1306 if copy_operations:
1307 hosting_client = getUtility(IGitHostingClient)
1308 hosting_client.copyRefs(
1309 git_repository.getInternalPath(), copy_operations)
1310 return copy_operations
1311
1220 def scheduleDiffUpdates(self, return_jobs=True):1312 def scheduleDiffUpdates(self, return_jobs=True):
1221 """See `IBranchMergeProposal`."""1313 """See `IBranchMergeProposal`."""
1222 from lp.code.model.branchmergeproposaljob import (1314 from lp.code.model.branchmergeproposaljob import (
diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py
index 74f4ec0..5fe859b 100644
--- a/lib/lp/code/model/githosting.py
+++ b/lib/lp/code/model/githosting.py
@@ -33,7 +33,6 @@ from lp.code.errors import (
33 GitRepositoryDeletionFault,33 GitRepositoryDeletionFault,
34 GitRepositoryScanFault,34 GitRepositoryScanFault,
35 GitTargetError,35 GitTargetError,
36 NoSuchGitReference,
37 )36 )
38from lp.code.interfaces.githosting import IGitHostingClient37from lp.code.interfaces.githosting import IGitHostingClient
39from lp.services.config import config38from lp.services.config import config
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index fb5e703..13d67db 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -1184,9 +1184,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
1184 def updateLandingTargets(self, paths):1184 def updateLandingTargets(self, paths):
1185 """See `IGitRepository`."""1185 """See `IGitRepository`."""
1186 jobs = []1186 jobs = []
1187 for merge_proposal in self.getActiveLandingTargets(paths):1187 merge_proposals = self.getActiveLandingTargets(paths)
1188 for merge_proposal in merge_proposals:
1188 jobs.extend(merge_proposal.scheduleDiffUpdates())1189 jobs.extend(merge_proposal.scheduleDiffUpdates())
1189 return jobs1190 copy_operations = BranchMergeProposal.bulkSyncGitVirtualRefs(
1191 self, merge_proposals)
1192 return jobs, copy_operations
11901193
1191 def _getRecipes(self, paths=None):1194 def _getRecipes(self, paths=None):
1192 """Undecorated version of recipes for use by `markRecipesStale`."""1195 """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 81b94b3..bdedc8b 100644
--- a/lib/lp/code/model/tests/test_branchmergeproposal.py
+++ b/lib/lp/code/model/tests/test_branchmergeproposal.py
@@ -67,6 +67,10 @@ from lp.code.interfaces.branchmergeproposal import (
67 IBranchMergeProposalGetter,67 IBranchMergeProposalGetter,
68 IBranchMergeProposalJobSource,68 IBranchMergeProposalJobSource,
69 )69 )
70from lp.code.interfaces.gitrepository import (
71 GIT_CREATE_MP_VIRTUAL_REF,
72 GIT_MP_VIRTUAL_REF_FORMAT,
73 )
70from lp.code.model.branchmergeproposal import (74from lp.code.model.branchmergeproposal import (
71 BranchMergeProposal,75 BranchMergeProposal,
72 BranchMergeProposalGetter,76 BranchMergeProposalGetter,
@@ -97,6 +101,7 @@ from lp.testing import (
97 ExpectedException,101 ExpectedException,
98 launchpadlib_for,102 launchpadlib_for,
99 login,103 login,
104 login_admin,
100 login_person,105 login_person,
101 person_logged_in,106 person_logged_in,
102 TestCaseWithFactory,107 TestCaseWithFactory,
@@ -256,6 +261,137 @@ class TestBranchMergeProposalPrivacy(TestCaseWithFactory):
256 self.assertContentEqual([owner, team], subscriptions)261 self.assertContentEqual([owner, team], subscriptions)
257262
258263
264class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory):
265 """Ensure that BranchMergeProposal creation run the appropriate copy
266 and delete of virtual refs, like ref/merge/<id>/head."""
267
268 layer = DatabaseFunctionalLayer
269
270 def setUp(self):
271 super(TestGitBranchMergeProposalVirtualRefs, self).setUp()
272 self.hosting_fixture = self.useFixture(GitHostingFixture())
273
274 def test_copy_git_merge_virtual_ref(self):
275 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
276 mp = self.factory.makeBranchMergeProposalForGit()
277
278 copy_operations = mp.getGitRefCopyOperations()
279 self.assertEqual(1, len(copy_operations))
280 self.assertThat(copy_operations[0], MatchesStructure(
281 source_ref=Equals(mp.source_git_commit_sha1),
282 target_repo=Equals(mp.target_git_repository.getInternalPath()),
283 target_ref=Equals("refs/merge/%s/head" % mp.id),
284 ))
285
286 self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
287 args, kwargs = self.hosting_fixture.copyRefs.calls[0]
288 arg_path, arg_copy_operations = args
289 self.assertEqual({}, kwargs)
290 self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path)
291 self.assertEqual(1, len(arg_copy_operations))
292 self.assertThat(arg_copy_operations[0], MatchesStructure(
293 source_ref=Equals(mp.source_git_commit_sha1),
294 target_repo=Equals(mp.target_git_repository.getInternalPath()),
295 target_ref=Equals("refs/merge/%s/head" % mp.id),
296 ))
297
298 def test_getGitRefCopyOperations_private_source(self):
299 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
300 login_admin()
301 source_repo = self.factory.makeGitRepository(
302 information_type=InformationType.PRIVATESECURITY)
303 target_repo = self.factory.makeGitRepository(target=source_repo.target)
304 [source] = self.factory.makeGitRefs(source_repo)
305 [target] = self.factory.makeGitRefs(target_repo)
306 mp = self.factory.makeBranchMergeProposalForGit(
307 source_ref=source, target_ref=target)
308 self.assertEqual([], mp.getGitRefCopyOperations())
309
310 def test_getGitRefCopyOperations_private_source_same_repo(self):
311 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
312 login_admin()
313 repo = self.factory.makeGitRepository(
314 information_type=InformationType.PRIVATESECURITY)
315 [source, target] = self.factory.makeGitRefs(
316 repo, ['refs/heads/bugfix', 'refs/heads/master'])
317 mp = self.factory.makeBranchMergeProposalForGit(
318 source_ref=source, target_ref=target)
319 operations = mp.getGitRefCopyOperations()
320 self.assertEqual(1, len(operations))
321 self.assertThat(operations[0], MatchesStructure(
322 source_ref=Equals(mp.source_git_commit_sha1),
323 target_repo=Equals(mp.target_git_repository.getInternalPath()),
324 target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
325 ))
326
327 def test_getGitRefCopyOperations_private_source_same_owner(self):
328 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
329 login_admin()
330 source_repo = self.factory.makeGitRepository(
331 information_type=InformationType.PRIVATESECURITY)
332 target_repo = self.factory.makeGitRepository(
333 target=source_repo.target, owner=source_repo.owner)
334 [source] = self.factory.makeGitRefs(source_repo)
335 [target] = self.factory.makeGitRefs(target_repo)
336 mp = self.factory.makeBranchMergeProposalForGit(
337 source_ref=source, target_ref=target)
338 operations = mp.getGitRefCopyOperations()
339 self.assertEqual(1, len(operations))
340 self.assertThat(operations[0], MatchesStructure(
341 source_ref=Equals(mp.source_git_commit_sha1),
342 target_repo=Equals(mp.target_git_repository.getInternalPath()),
343 target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
344 ))
345
346 def test_syncGitVirtualRefs(self):
347 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
348 login_admin()
349 login_admin()
350 source_repo = self.factory.makeGitRepository()
351 target_repo = self.factory.makeGitRepository(target=source_repo.target)
352 [source] = self.factory.makeGitRefs(source_repo)
353 [target] = self.factory.makeGitRefs(target_repo)
354 mp = self.factory.makeBranchMergeProposalForGit(
355 source_ref=source, target_ref=target)
356
357 # mp.syncGitVirtualRefs should have been triggered by event.
358 # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
359 self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
360 args, kwargs = self.hosting_fixture.copyRefs.calls[0]
361 self.assertEquals({}, kwargs)
362 self.assertEqual(args[0], source_repo.getInternalPath())
363 self.assertEqual(1, len(args[1]))
364 self.assertThat(args[1][0], MatchesStructure(
365 source_ref=Equals(mp.source_git_commit_sha1),
366 target_repo=Equals(mp.target_git_repository.getInternalPath()),
367 target_ref=Equals("refs/merge/%s/head" % mp.id),
368 ))
369
370 self.assertEqual(0, self.hosting_fixture.deleteRefs.call_count)
371
372 def test_syncGitVirtualRefs_private_source_deletes_ref(self):
373 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
374 login_admin()
375 source_repo = self.factory.makeGitRepository(
376 information_type=InformationType.PRIVATESECURITY)
377 target_repo = self.factory.makeGitRepository(target=source_repo.target)
378 [source] = self.factory.makeGitRefs(source_repo)
379 [target] = self.factory.makeGitRefs(target_repo)
380 mp = self.factory.makeBranchMergeProposalForGit(
381 source_ref=source, target_ref=target)
382
383 # mp.syncGitVirtualRefs should have been triggered by event.
384 # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
385 self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
386 self.assertEqual(1, self.hosting_fixture.deleteRefs.call_count)
387 args, kwargs = self.hosting_fixture.deleteRefs.calls[0]
388 self.assertEqual({}, kwargs)
389 self.assertEqual(
390 ([(target_repo.getInternalPath(),
391 "refs/merge/%s/head" % mp.id)], ),
392 args)
393
394
259class TestBranchMergeProposalTransitions(TestCaseWithFactory):395class TestBranchMergeProposalTransitions(TestCaseWithFactory):
260 """Test the state transitions of branch merge proposals."""396 """Test the state transitions of branch merge proposals."""
261397
@@ -1185,6 +1321,10 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
1185 def test_delete_triggers_webhooks(self):1321 def test_delete_triggers_webhooks(self):
1186 # When an existing merge proposal is deleted, any relevant webhooks1322 # When an existing merge proposal is deleted, any relevant webhooks
1187 # are triggered.1323 # are triggered.
1324 self.useFixture(FeatureFixture({
1325 GIT_CREATE_MP_VIRTUAL_REF: "on",
1326 BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG: "on"}))
1327 hosting_fixture = self.useFixture(GitHostingFixture())
1188 logger = self.useFixture(FakeLogger())1328 logger = self.useFixture(FakeLogger())
1189 source = self.makeBranch()1329 source = self.makeBranch()
1190 target = self.makeBranch(same_target_as=source)1330 target = self.makeBranch(same_target_as=source)
@@ -1205,8 +1345,11 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
1205 old=MatchesDict(self.getExpectedPayload(proposal, redact=True)))1345 old=MatchesDict(self.getExpectedPayload(proposal, redact=True)))
1206 proposal.deleteProposal()1346 proposal.deleteProposal()
1207 delivery = hook.deliveries.one()1347 delivery = hook.deliveries.one()
1348 self.assertIsNotNone(delivery)
1208 self.assertCorrectDelivery(expected_payload, hook, delivery)1349 self.assertCorrectDelivery(expected_payload, hook, delivery)
1209 self.assertCorrectLogging(expected_redacted_payload, hook, logger)1350 self.assertCorrectLogging(expected_redacted_payload, hook, logger)
1351 self.assertEqual(
1352 1 if self.git else 0, hosting_fixture.deleteRefs.call_count)
12101353
12111354
1212class TestGetAddress(TestCaseWithFactory):1355class TestGetAddress(TestCaseWithFactory):
@@ -1507,6 +1650,18 @@ class TestBranchMergeProposalDeletion(TestCaseWithFactory):
1507 self.assertRaises(1650 self.assertRaises(
1508 SQLObjectNotFound, BranchMergeProposalJob.get, job_id)1651 SQLObjectNotFound, BranchMergeProposalJob.get, job_id)
15091652
1653 def test_deleteProposal_for_git_removes_virtual_ref(self):
1654 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
1655 hosting_fixture = self.useFixture(GitHostingFixture())
1656 proposal = self.factory.makeBranchMergeProposalForGit()
1657 proposal.deleteProposal()
1658
1659 self.assertEqual(1, hosting_fixture.deleteRefs.call_count)
1660 args = hosting_fixture.deleteRefs.calls[0]
1661 self.assertEqual((
1662 (([(proposal.target_git_repository.getInternalPath(),
1663 'refs/merge/%s/head' % proposal.id)]), ), {}), args)
1664
15101665
1511class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory):1666class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory):
15121667
diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
index 23d6063..5ca77d3 100644
--- a/lib/lp/code/model/tests/test_githosting.py
+++ b/lib/lp/code/model/tests/test_githosting.py
@@ -42,7 +42,6 @@ from lp.code.errors import (
42 GitRepositoryDeletionFault,42 GitRepositoryDeletionFault,
43 GitRepositoryScanFault,43 GitRepositoryScanFault,
44 GitTargetError,44 GitTargetError,
45 NoSuchGitReference,
46 )45 )
47from lp.code.interfaces.githosting import IGitHostingClient46from lp.code.interfaces.githosting import IGitHostingClient
48from lp.code.model.githosting import RefCopyOperation47from lp.code.model.githosting import RefCopyOperation
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index b83f9da..53d9d57 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -97,6 +97,7 @@ from lp.code.interfaces.gitnamespace import (
97 IGitNamespaceSet,97 IGitNamespaceSet,
98 )98 )
99from lp.code.interfaces.gitrepository import (99from lp.code.interfaces.gitrepository import (
100 GIT_CREATE_MP_VIRTUAL_REF,
100 IGitRepository,101 IGitRepository,
101 IGitRepositorySet,102 IGitRepositorySet,
102 IGitRepositoryView,103 IGitRepositoryView,
@@ -783,6 +784,7 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
783 # Make sure that the tests all flush the database changes.784 # Make sure that the tests all flush the database changes.
784 self.addCleanup(Store.of(self.repository).flush)785 self.addCleanup(Store.of(self.repository).flush)
785 login_person(self.user)786 login_person(self.user)
787 self.hosting_fixture = self.useFixture(GitHostingFixture())
786788
787 def test_deletable(self):789 def test_deletable(self):
788 # A newly created repository can be deleted without any problems.790 # A newly created repository can be deleted without any problems.
@@ -824,7 +826,6 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
824826
825 def test_code_import_does_not_disable_deletion(self):827 def test_code_import_does_not_disable_deletion(self):
826 # A repository that has an attached code import can be deleted.828 # A repository that has an attached code import can be deleted.
827 self.useFixture(GitHostingFixture())
828 code_import = self.factory.makeCodeImport(829 code_import = self.factory.makeCodeImport(
829 target_rcs_type=TargetRevisionControlSystems.GIT)830 target_rcs_type=TargetRevisionControlSystems.GIT)
830 repository = code_import.git_repository831 repository = code_import.git_repository
@@ -984,6 +985,7 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
984 # unsubscribe the repository owner here.985 # unsubscribe the repository owner here.
985 self.repository.unsubscribe(986 self.repository.unsubscribe(
986 self.repository.owner, self.repository.owner)987 self.repository.owner, self.repository.owner)
988 self.hosting_fixture = self.useFixture(GitHostingFixture())
987989
988 def test_plain_repository(self):990 def test_plain_repository(self):
989 # A fresh repository has no deletion requirements.991 # A fresh repository has no deletion requirements.
@@ -1115,7 +1117,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
11151117
1116 def test_code_import_requirements(self):1118 def test_code_import_requirements(self):
1117 # Code imports are not included explicitly in deletion requirements.1119 # Code imports are not included explicitly in deletion requirements.
1118 self.useFixture(GitHostingFixture())
1119 code_import = self.factory.makeCodeImport(1120 code_import = self.factory.makeCodeImport(
1120 target_rcs_type=TargetRevisionControlSystems.GIT)1121 target_rcs_type=TargetRevisionControlSystems.GIT)
1121 # Remove the implicit repository subscription first.1122 # Remove the implicit repository subscription first.
@@ -1126,7 +1127,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
11261127
1127 def test_code_import_deletion(self):1128 def test_code_import_deletion(self):
1128 # break_references allows deleting a code import repository.1129 # break_references allows deleting a code import repository.
1129 self.useFixture(GitHostingFixture())
1130 code_import = self.factory.makeCodeImport(1130 code_import = self.factory.makeCodeImport(
1131 target_rcs_type=TargetRevisionControlSystems.GIT)1131 target_rcs_type=TargetRevisionControlSystems.GIT)
1132 code_import_id = code_import.id1132 code_import_id = code_import.id
@@ -1182,7 +1182,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
11821182
1183 def test_DeleteCodeImport(self):1183 def test_DeleteCodeImport(self):
1184 # DeleteCodeImport.__call__ must delete the CodeImport.1184 # DeleteCodeImport.__call__ must delete the CodeImport.
1185 self.useFixture(GitHostingFixture())
1186 code_import = self.factory.makeCodeImport(1185 code_import = self.factory.makeCodeImport(
1187 target_rcs_type=TargetRevisionControlSystems.GIT)1186 target_rcs_type=TargetRevisionControlSystems.GIT)
1188 code_import_id = code_import.id1187 code_import_id = code_import.id
@@ -1521,6 +1520,10 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
15211520
1522 layer = DatabaseFunctionalLayer1521 layer = DatabaseFunctionalLayer
15231522
1523 def setUp(self):
1524 super(TestGitRepositoryRefs, self).setUp()
1525 self.hosting_fixture = self.useFixture(GitHostingFixture())
1526
1524 def test__convertRefInfo(self):1527 def test__convertRefInfo(self):
1525 # _convertRefInfo converts a valid info dictionary.1528 # _convertRefInfo converts a valid info dictionary.
1526 sha1 = six.ensure_text(hashlib.sha1("").hexdigest())1529 sha1 = six.ensure_text(hashlib.sha1("").hexdigest())
@@ -1791,18 +1794,17 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1791 # planRefChanges excludes some ref prefixes by default, and can be1794 # planRefChanges excludes some ref prefixes by default, and can be
1792 # configured otherwise.1795 # configured otherwise.
1793 repository = self.factory.makeGitRepository()1796 repository = self.factory.makeGitRepository()
1794 hosting_fixture = self.useFixture(GitHostingFixture())
1795 repository.planRefChanges("dummy")1797 repository.planRefChanges("dummy")
1796 self.assertEqual(1798 self.assertEqual(
1797 [{"exclude_prefixes": ["refs/changes/"]}],1799 [{"exclude_prefixes": ["refs/changes/"]}],
1798 hosting_fixture.getRefs.extract_kwargs())1800 self.hosting_fixture.getRefs.extract_kwargs())
1799 hosting_fixture.getRefs.calls = []1801 self.hosting_fixture.getRefs.calls = []
1800 self.pushConfig(1802 self.pushConfig(
1801 "codehosting", git_exclude_ref_prefixes="refs/changes/ refs/pull/")1803 "codehosting", git_exclude_ref_prefixes="refs/changes/ refs/pull/")
1802 repository.planRefChanges("dummy")1804 repository.planRefChanges("dummy")
1803 self.assertEqual(1805 self.assertEqual(
1804 [{"exclude_prefixes": ["refs/changes/", "refs/pull/"]}],1806 [{"exclude_prefixes": ["refs/changes/", "refs/pull/"]}],
1805 hosting_fixture.getRefs.extract_kwargs())1807 self.hosting_fixture.getRefs.extract_kwargs())
18061808
1807 def test_fetchRefCommits(self):1809 def test_fetchRefCommits(self):
1808 # fetchRefCommits fetches detailed tip commit metadata for the1810 # fetchRefCommits fetches detailed tip commit metadata for the
@@ -1875,9 +1877,8 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1875 def test_fetchRefCommits_empty(self):1877 def test_fetchRefCommits_empty(self):
1876 # If given an empty refs dictionary, fetchRefCommits returns early1878 # If given an empty refs dictionary, fetchRefCommits returns early
1877 # without contacting the hosting service.1879 # without contacting the hosting service.
1878 hosting_fixture = self.useFixture(GitHostingFixture())
1879 GitRepository.fetchRefCommits("dummy", {})1880 GitRepository.fetchRefCommits("dummy", {})
1880 self.assertEqual([], hosting_fixture.getCommits.calls)1881 self.assertEqual([], self.hosting_fixture.getCommits.calls)
18811882
1882 def test_synchroniseRefs(self):1883 def test_synchroniseRefs(self):
1883 # synchroniseRefs copes with synchronising a repository where some1884 # synchroniseRefs copes with synchronising a repository where some
@@ -1920,7 +1921,6 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1920 self.assertThat(repository.refs, MatchesSetwise(*matchers))1921 self.assertThat(repository.refs, MatchesSetwise(*matchers))
19211922
1922 def test_set_default_branch(self):1923 def test_set_default_branch(self):
1923 hosting_fixture = self.useFixture(GitHostingFixture())
1924 repository = self.factory.makeGitRepository()1924 repository = self.factory.makeGitRepository()
1925 self.factory.makeGitRefs(1925 self.factory.makeGitRefs(
1926 repository=repository,1926 repository=repository,
@@ -1931,22 +1931,20 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1931 self.assertEqual(1931 self.assertEqual(
1932 [((repository.getInternalPath(),),1932 [((repository.getInternalPath(),),
1933 {"default_branch": "refs/heads/new"})],1933 {"default_branch": "refs/heads/new"})],
1934 hosting_fixture.setProperties.calls)1934 self.hosting_fixture.setProperties.calls)
1935 self.assertEqual("refs/heads/new", repository.default_branch)1935 self.assertEqual("refs/heads/new", repository.default_branch)
19361936
1937 def test_set_default_branch_unchanged(self):1937 def test_set_default_branch_unchanged(self):
1938 hosting_fixture = self.useFixture(GitHostingFixture())
1939 repository = self.factory.makeGitRepository()1938 repository = self.factory.makeGitRepository()
1940 self.factory.makeGitRefs(1939 self.factory.makeGitRefs(
1941 repository=repository, paths=["refs/heads/master"])1940 repository=repository, paths=["refs/heads/master"])
1942 removeSecurityProxy(repository)._default_branch = "refs/heads/master"1941 removeSecurityProxy(repository)._default_branch = "refs/heads/master"
1943 with person_logged_in(repository.owner):1942 with person_logged_in(repository.owner):
1944 repository.default_branch = "master"1943 repository.default_branch = "master"
1945 self.assertEqual([], hosting_fixture.setProperties.calls)1944 self.assertEqual([], self.hosting_fixture.setProperties.calls)
1946 self.assertEqual("refs/heads/master", repository.default_branch)1945 self.assertEqual("refs/heads/master", repository.default_branch)
19471946
1948 def test_set_default_branch_imported(self):1947 def test_set_default_branch_imported(self):
1949 hosting_fixture = self.useFixture(GitHostingFixture())
1950 repository = self.factory.makeGitRepository(1948 repository = self.factory.makeGitRepository(
1951 repository_type=GitRepositoryType.IMPORTED)1949 repository_type=GitRepositoryType.IMPORTED)
1952 self.factory.makeGitRefs(1950 self.factory.makeGitRefs(
@@ -1959,7 +1957,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
1959 "Cannot modify non-hosted Git repository %s." %1957 "Cannot modify non-hosted Git repository %s." %
1960 repository.display_name,1958 repository.display_name,
1961 setattr, repository, "default_branch", "new")1959 setattr, repository, "default_branch", "new")
1962 self.assertEqual([], hosting_fixture.setProperties.calls)1960 self.assertEqual([], self.hosting_fixture.setProperties.calls)
1963 self.assertEqual("refs/heads/master", repository.default_branch)1961 self.assertEqual("refs/heads/master", repository.default_branch)
19641962
1965 def test_exception_unset_default_branch(self):1963 def test_exception_unset_default_branch(self):
@@ -2580,18 +2578,59 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory):
25802578
2581 layer = DatabaseFunctionalLayer2579 layer = DatabaseFunctionalLayer
25822580
2583 def test_schedules_diff_updates(self):2581 def setUp(self):
2582 super(TestGitRepositoryUpdateLandingTargets, self).setUp()
2583 self.hosting_fixture = self.useFixture(GitHostingFixture())
2584
2585 def assertSchedulesDiffUpdate(self, with_mp_virtual_ref):
2584 """Create jobs for all merge proposals."""2586 """Create jobs for all merge proposals."""
2585 bmp1 = self.factory.makeBranchMergeProposalForGit()2587 bmp1 = self.factory.makeBranchMergeProposalForGit()
2586 bmp2 = self.factory.makeBranchMergeProposalForGit(2588 bmp2 = self.factory.makeBranchMergeProposalForGit(
2587 source_ref=bmp1.source_git_ref)2589 source_ref=bmp1.source_git_ref)
2588 jobs = bmp1.source_git_repository.updateLandingTargets(2590
2591 # Only enable this virtual ref here, since
2592 # self.factory.makeBranchMergeProposalForGit also tries to create
2593 # the virtual refs.
2594 if with_mp_virtual_ref:
2595 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
2596 else:
2597 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: ""}))
2598 jobs, ref_copies = bmp1.source_git_repository.updateLandingTargets(
2589 [bmp1.source_git_path])2599 [bmp1.source_git_path])
2590 self.assertEqual(2, len(jobs))2600 self.assertEqual(2, len(jobs))
2591 bmps_to_update = [2601 bmps_to_update = [
2592 removeSecurityProxy(job).branch_merge_proposal for job in jobs]2602 removeSecurityProxy(job).branch_merge_proposal for job in jobs]
2593 self.assertContentEqual([bmp1, bmp2], bmps_to_update)2603 self.assertContentEqual([bmp1, bmp2], bmps_to_update)
25942604
2605 if not with_mp_virtual_ref:
2606 self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
2607 else:
2608 self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
2609 args, kwargs = self.hosting_fixture.copyRefs.calls[0]
2610 self.assertEqual({}, kwargs)
2611 self.assertEqual(2, len(args))
2612 path, operations = args
2613 self.assertEqual(
2614 path, bmp1.source_git_repository.getInternalPath())
2615 self.assertThat(operations[0], MatchesStructure(
2616 source_ref=Equals(bmp1.source_git_commit_sha1),
2617 target_repo=Equals(
2618 bmp1.target_git_repository.getInternalPath()),
2619 target_ref=Equals("refs/merge/%s/head" % bmp1.id),
2620 ))
2621 self.assertThat(operations[1], MatchesStructure(
2622 source_ref=Equals(bmp2.source_git_commit_sha1),
2623 target_repo=Equals(
2624 bmp2.target_git_repository.getInternalPath()),
2625 target_ref=Equals("refs/merge/%s/head" % bmp2.id),
2626 ))
2627
2628 def test_schedules_diff_updates_with_mp_virtual_ref(self):
2629 self.assertSchedulesDiffUpdate(with_mp_virtual_ref=True)
2630
2631 def test_schedules_diff_updates_without_mp_virtual_ref(self):
2632 self.assertSchedulesDiffUpdate(with_mp_virtual_ref=False)
2633
2595 def test_ignores_final(self):2634 def test_ignores_final(self):
2596 """Diffs for proposals in final states aren't updated."""2635 """Diffs for proposals in final states aren't updated."""
2597 [source_ref] = self.factory.makeGitRefs()2636 [source_ref] = self.factory.makeGitRefs()
@@ -2603,8 +2642,15 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory):
2603 for bmp in source_ref.landing_targets:2642 for bmp in source_ref.landing_targets:
2604 if bmp.queue_status not in FINAL_STATES:2643 if bmp.queue_status not in FINAL_STATES:
2605 removeSecurityProxy(bmp).deleteProposal()2644 removeSecurityProxy(bmp).deleteProposal()
2606 jobs = source_ref.repository.updateLandingTargets([source_ref.path])2645
2646 # Enable the feature here, since factory.makeBranchMergeProposalForGit
2647 # would also trigger the copy refs call.
2648 self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
2649 jobs, ref_copies = source_ref.repository.updateLandingTargets(
2650 [source_ref.path])
2607 self.assertEqual(0, len(jobs))2651 self.assertEqual(0, len(jobs))
2652 self.assertEqual(0, len(ref_copies))
2653 self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
26082654
26092655
2610class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):2656class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py
index a214c8f..a34ca2a 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.syncGitVirtualRefs()
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",
@@ -149,3 +153,5 @@ def merge_proposal_deleted(merge_proposal, event):
149 "old": _compose_merge_proposal_webhook_payload(merge_proposal),153 "old": _compose_merge_proposal_webhook_payload(merge_proposal),
150 }154 }
151 _trigger_webhook(merge_proposal, payload)155 _trigger_webhook(merge_proposal, payload)
156
157 merge_proposal.syncGitVirtualRefs(force_delete=True)
diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py
index 4ef843d..e0ab275 100644
--- a/lib/lp/code/tests/helpers.py
+++ b/lib/lp/code/tests/helpers.py
@@ -369,6 +369,8 @@ class GitHostingFixture(fixtures.Fixture):
369 self.getBlob = FakeMethod(result=blob)369 self.getBlob = FakeMethod(result=blob)
370 self.delete = FakeMethod()370 self.delete = FakeMethod()
371 self.disable_memcache = disable_memcache371 self.disable_memcache = disable_memcache
372 self.copyRefs = FakeMethod()
373 self.deleteRefs = FakeMethod()
372374
373 def _setUp(self):375 def _setUp(self):
374 self.useFixture(ZopeUtilityFixture(self, IGitHostingClient))376 self.useFixture(ZopeUtilityFixture(self, IGitHostingClient))