Merge ~pappacena/launchpad:create-mp-refs into launchpad:master
- Git
- lp:~pappacena/launchpad
- create-mp-refs
- Merge into master
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) |
Related bugs: |
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.
Description of the change
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/
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:/
Do you see any specific privacy problem with this scenario?
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/
>
> 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:/
>
> 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.
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?
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://
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.
Thiago F. Pappacena (pappacena) wrote : | # |
The MP with the job that should re-sync the virtual refs is here: https:/
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.
Colin Watson (cjwatson) wrote : | # |
Also please set a commit message.
- 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
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.
- 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
1 | diff --git a/lib/lp/code/interfaces/branchmergeproposal.py b/lib/lp/code/interfaces/branchmergeproposal.py |
2 | index bf481c9..449e921 100644 |
3 | --- a/lib/lp/code/interfaces/branchmergeproposal.py |
4 | +++ b/lib/lp/code/interfaces/branchmergeproposal.py |
5 | @@ -1,4 +1,4 @@ |
6 | -# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
7 | +# Copyright 2009-2020 Canonical Ltd. This software is licensed under the |
8 | # GNU Affero General Public License version 3 (see the file LICENSE). |
9 | |
10 | """The interface for branch merge proposals.""" |
11 | @@ -519,6 +519,31 @@ class IBranchMergeProposalView(Interface): |
12 | def getLatestDiffUpdateJob(): |
13 | """Return the latest IUpdatePreviewDiffJob for this MP.""" |
14 | |
15 | + def getGitRefCopyOperations(): |
16 | + """Gets the list of GitHosting copy operations that should be done |
17 | + in order to keep this MP's virtual refs up-to-date. |
18 | + |
19 | + :return: A list of RefCopyOperation objects. |
20 | + """ |
21 | + |
22 | + def syncGitVirtualRefs(force_delete=False): |
23 | + """Requests all copies and deletion of virtual refs to make git code |
24 | + hosting in sync with this MP. |
25 | + |
26 | + :param force_delete: Do not try to copy any new virtual ref. Just |
27 | + delete all virtual refs possibly created. |
28 | + """ |
29 | + |
30 | + def bulkSyncGitVirtualRefs(git_repository, merge_proposals): |
31 | + """Synchronizes a set of merge proposals' virtual refs. |
32 | + |
33 | + :params git_repository: The source git repository of the given merge |
34 | + proposals. |
35 | + :params merge_proposals: The set of merge proposals that should have |
36 | + the virtual refs synced. |
37 | + :return: A list of copy operations executed. |
38 | + """ |
39 | + |
40 | |
41 | class IBranchMergeProposalEdit(Interface): |
42 | |
43 | diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py |
44 | index 192133a..48e7dd3 100644 |
45 | --- a/lib/lp/code/interfaces/gitrepository.py |
46 | +++ b/lib/lp/code/interfaces/gitrepository.py |
47 | @@ -8,6 +8,8 @@ __metaclass__ = type |
48 | __all__ = [ |
49 | 'ContributorGitIdentity', |
50 | 'GitIdentityMixin', |
51 | + 'GIT_CREATE_MP_VIRTUAL_REF', |
52 | + 'GIT_MP_VIRTUAL_REF_FORMAT', |
53 | 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE', |
54 | 'git_repository_name_validator', |
55 | 'IGitRepository', |
56 | @@ -105,6 +107,11 @@ valid_git_repository_name_pattern = re.compile( |
57 | r"^(?i)[a-z0-9][a-z0-9+\.\-@_]*\Z") |
58 | |
59 | |
60 | +# Virtual ref where we automatically put a copy of any open merge proposal. |
61 | +GIT_MP_VIRTUAL_REF_FORMAT = 'refs/merge/{mp.id}/head' |
62 | +GIT_CREATE_MP_VIRTUAL_REF = 'git.mergeproposal_virtualref.enabled' |
63 | + |
64 | + |
65 | def valid_git_repository_name(name): |
66 | """Return True iff the name is valid as a Git repository name. |
67 | |
68 | @@ -595,11 +602,15 @@ class IGitRepositoryView(IHasRecipes): |
69 | def updateLandingTargets(paths): |
70 | """Update landing targets (MPs where this repository is the source). |
71 | |
72 | - For each merge proposal, create `UpdatePreviewDiffJob`s. |
73 | + For each merge proposal, create `UpdatePreviewDiffJob`s, and runs |
74 | + the appropriate GitHosting.copyRefs operation to allow having |
75 | + virtual merge refs with the updated branch version. |
76 | |
77 | :param paths: A list of reference paths. Any merge proposals whose |
78 | source is this repository and one of these paths will have their |
79 | diffs updated. |
80 | + :return: Returns a tuple with the list of background jobs created, |
81 | + and the list of ref copies requested to GitHosting. |
82 | """ |
83 | |
84 | def markRecipesStale(paths): |
85 | diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py |
86 | index 061fb1e..ec65e98 100644 |
87 | --- a/lib/lp/code/model/branchmergeproposal.py |
88 | +++ b/lib/lp/code/model/branchmergeproposal.py |
89 | @@ -12,6 +12,7 @@ __all__ = [ |
90 | |
91 | from collections import defaultdict |
92 | from email.utils import make_msgid |
93 | +import logging |
94 | from operator import attrgetter |
95 | import sys |
96 | |
97 | @@ -84,7 +85,12 @@ from lp.code.interfaces.codereviewcomment import ICodeReviewComment |
98 | from lp.code.interfaces.codereviewinlinecomment import ( |
99 | ICodeReviewInlineCommentSet, |
100 | ) |
101 | +from lp.code.interfaces.githosting import IGitHostingClient |
102 | from lp.code.interfaces.gitref import IGitRef |
103 | +from lp.code.interfaces.gitrepository import ( |
104 | + GIT_CREATE_MP_VIRTUAL_REF, |
105 | + GIT_MP_VIRTUAL_REF_FORMAT, |
106 | + ) |
107 | from lp.code.mail.branch import RecipientReason |
108 | from lp.code.model.branchrevision import BranchRevision |
109 | from lp.code.model.codereviewcomment import CodeReviewComment |
110 | @@ -94,6 +100,7 @@ from lp.code.model.diff import ( |
111 | IncrementalDiff, |
112 | PreviewDiff, |
113 | ) |
114 | +from lp.code.model.githosting import RefCopyOperation |
115 | from lp.registry.interfaces.person import ( |
116 | IPerson, |
117 | IPersonSet, |
118 | @@ -123,6 +130,7 @@ from lp.services.database.sqlbase import ( |
119 | quote, |
120 | SQLBase, |
121 | ) |
122 | +from lp.services.features import getFeatureFlag |
123 | from lp.services.helpers import shortlist |
124 | from lp.services.job.interfaces.job import JobStatus |
125 | from lp.services.job.model.job import Job |
126 | @@ -144,6 +152,9 @@ from lp.soyuz.enums import ( |
127 | ) |
128 | |
129 | |
130 | +logger = logging.getLogger(__name__) |
131 | + |
132 | + |
133 | def is_valid_transition(proposal, from_state, next_state, user=None): |
134 | """Is it valid for this user to move this proposal to to next_state? |
135 | |
136 | @@ -971,6 +982,7 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): |
137 | branch_merge_proposal=self.id): |
138 | job.destroySelf() |
139 | self._preview_diffs.remove() |
140 | + |
141 | self.destroySelf() |
142 | |
143 | def getUnlandedSourceBranchRevisions(self): |
144 | @@ -1217,6 +1229,86 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): |
145 | for diff in diffs) |
146 | return [diff_dict.get(revisions) for revisions in revision_list] |
147 | |
148 | + def getGitRefCopyOperations(self): |
149 | + """See `IBranchMergeProposal`""" |
150 | + if (not self.target_git_repository |
151 | + or not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF)): |
152 | + return [] |
153 | + # If the source repository is private and it's opening a MP to |
154 | + # another repository, we should not risk disclosing the private code to |
155 | + # everyone with permission to see the target repo: |
156 | + private_source = self.source_git_repository.private |
157 | + same_owner = self.source_git_repository.owner.inTeam( |
158 | + self.target_git_repository.owner) |
159 | + if private_source and not same_owner: |
160 | + return [] |
161 | + return [RefCopyOperation( |
162 | + self.source_git_commit_sha1, |
163 | + self.target_git_repository.getInternalPath(), |
164 | + GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))] |
165 | + |
166 | + def _copyGitVirtualRefs(self): |
167 | + """Requests virtual refs copy operations on GitHosting in order to |
168 | + keep them up-to-date with current MP's state. |
169 | + |
170 | + :return: The list of copied refs. |
171 | + """ |
172 | + copy_operations = self.getGitRefCopyOperations() |
173 | + if copy_operations: |
174 | + hosting_client = getUtility(IGitHostingClient) |
175 | + hosting_client.copyRefs( |
176 | + self.source_git_repository.getInternalPath(), |
177 | + copy_operations) |
178 | + return [i.target_ref for i in copy_operations] |
179 | + |
180 | + def _deleteGitVirtualRefs(self, except_refs=None): |
181 | + """Deletes on git code hosting service all virtual refs, except |
182 | + those ones in the given list. |
183 | + |
184 | + For now, the only existing virtual ref to be deleted is |
185 | + GIT_CREATE_MP_VIRTUAL_REF. So this method will only ever delete at |
186 | + most 1 virtual ref. Once new virtual refs will be created, this method |
187 | + should be extended to delete them also. |
188 | + """ |
189 | + if self.source_git_ref is None: |
190 | + return |
191 | + if not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF): |
192 | + # XXX pappacena 2020-09-15: Do not try to remove virtual refs if |
193 | + # the feature is disabled. It might be disabled due to bug on |
194 | + # the first versions, for example, and we should have a way to |
195 | + # skip this feature entirely. |
196 | + # Once the feature is stable, we should actually *always* delete |
197 | + # the virtual refs, since we don't know if the feature was |
198 | + # enabled or not when the branch was created. |
199 | + return |
200 | + ref = GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self) |
201 | + if ref not in (except_refs or []): |
202 | + hosting_client = getUtility(IGitHostingClient) |
203 | + hosting_client.deleteRefs( |
204 | + [(self.target_git_repository.getInternalPath(), ref)]) |
205 | + |
206 | + def syncGitVirtualRefs(self, force_delete=False): |
207 | + """See `IBranchMergeProposal`""" |
208 | + if force_delete: |
209 | + # When force deletion of virtual refs, do not try to copy |
210 | + # anything. Just run the delete step. |
211 | + copied_refs = [] |
212 | + else: |
213 | + copied_refs = self._copyGitVirtualRefs() |
214 | + self._deleteGitVirtualRefs(except_refs=copied_refs) |
215 | + |
216 | + @classmethod |
217 | + def bulkSyncGitVirtualRefs(cls, git_repository, merge_proposals): |
218 | + """See `IBranchMergeProposal`""" |
219 | + copy_operations = [] |
220 | + for merge_proposal in merge_proposals: |
221 | + copy_operations += merge_proposal.getGitRefCopyOperations() |
222 | + if copy_operations: |
223 | + hosting_client = getUtility(IGitHostingClient) |
224 | + hosting_client.copyRefs( |
225 | + git_repository.getInternalPath(), copy_operations) |
226 | + return copy_operations |
227 | + |
228 | def scheduleDiffUpdates(self, return_jobs=True): |
229 | """See `IBranchMergeProposal`.""" |
230 | from lp.code.model.branchmergeproposaljob import ( |
231 | diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py |
232 | index 74f4ec0..5fe859b 100644 |
233 | --- a/lib/lp/code/model/githosting.py |
234 | +++ b/lib/lp/code/model/githosting.py |
235 | @@ -33,7 +33,6 @@ from lp.code.errors import ( |
236 | GitRepositoryDeletionFault, |
237 | GitRepositoryScanFault, |
238 | GitTargetError, |
239 | - NoSuchGitReference, |
240 | ) |
241 | from lp.code.interfaces.githosting import IGitHostingClient |
242 | from lp.services.config import config |
243 | diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py |
244 | index fb5e703..13d67db 100644 |
245 | --- a/lib/lp/code/model/gitrepository.py |
246 | +++ b/lib/lp/code/model/gitrepository.py |
247 | @@ -1184,9 +1184,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
248 | def updateLandingTargets(self, paths): |
249 | """See `IGitRepository`.""" |
250 | jobs = [] |
251 | - for merge_proposal in self.getActiveLandingTargets(paths): |
252 | + merge_proposals = self.getActiveLandingTargets(paths) |
253 | + for merge_proposal in merge_proposals: |
254 | jobs.extend(merge_proposal.scheduleDiffUpdates()) |
255 | - return jobs |
256 | + copy_operations = BranchMergeProposal.bulkSyncGitVirtualRefs( |
257 | + self, merge_proposals) |
258 | + return jobs, copy_operations |
259 | |
260 | def _getRecipes(self, paths=None): |
261 | """Undecorated version of recipes for use by `markRecipesStale`.""" |
262 | diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py |
263 | index 81b94b3..bdedc8b 100644 |
264 | --- a/lib/lp/code/model/tests/test_branchmergeproposal.py |
265 | +++ b/lib/lp/code/model/tests/test_branchmergeproposal.py |
266 | @@ -67,6 +67,10 @@ from lp.code.interfaces.branchmergeproposal import ( |
267 | IBranchMergeProposalGetter, |
268 | IBranchMergeProposalJobSource, |
269 | ) |
270 | +from lp.code.interfaces.gitrepository import ( |
271 | + GIT_CREATE_MP_VIRTUAL_REF, |
272 | + GIT_MP_VIRTUAL_REF_FORMAT, |
273 | + ) |
274 | from lp.code.model.branchmergeproposal import ( |
275 | BranchMergeProposal, |
276 | BranchMergeProposalGetter, |
277 | @@ -97,6 +101,7 @@ from lp.testing import ( |
278 | ExpectedException, |
279 | launchpadlib_for, |
280 | login, |
281 | + login_admin, |
282 | login_person, |
283 | person_logged_in, |
284 | TestCaseWithFactory, |
285 | @@ -256,6 +261,137 @@ class TestBranchMergeProposalPrivacy(TestCaseWithFactory): |
286 | self.assertContentEqual([owner, team], subscriptions) |
287 | |
288 | |
289 | +class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): |
290 | + """Ensure that BranchMergeProposal creation run the appropriate copy |
291 | + and delete of virtual refs, like ref/merge/<id>/head.""" |
292 | + |
293 | + layer = DatabaseFunctionalLayer |
294 | + |
295 | + def setUp(self): |
296 | + super(TestGitBranchMergeProposalVirtualRefs, self).setUp() |
297 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
298 | + |
299 | + def test_copy_git_merge_virtual_ref(self): |
300 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
301 | + mp = self.factory.makeBranchMergeProposalForGit() |
302 | + |
303 | + copy_operations = mp.getGitRefCopyOperations() |
304 | + self.assertEqual(1, len(copy_operations)) |
305 | + self.assertThat(copy_operations[0], MatchesStructure( |
306 | + source_ref=Equals(mp.source_git_commit_sha1), |
307 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
308 | + target_ref=Equals("refs/merge/%s/head" % mp.id), |
309 | + )) |
310 | + |
311 | + self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
312 | + args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
313 | + arg_path, arg_copy_operations = args |
314 | + self.assertEqual({}, kwargs) |
315 | + self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path) |
316 | + self.assertEqual(1, len(arg_copy_operations)) |
317 | + self.assertThat(arg_copy_operations[0], MatchesStructure( |
318 | + source_ref=Equals(mp.source_git_commit_sha1), |
319 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
320 | + target_ref=Equals("refs/merge/%s/head" % mp.id), |
321 | + )) |
322 | + |
323 | + def test_getGitRefCopyOperations_private_source(self): |
324 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
325 | + login_admin() |
326 | + source_repo = self.factory.makeGitRepository( |
327 | + information_type=InformationType.PRIVATESECURITY) |
328 | + target_repo = self.factory.makeGitRepository(target=source_repo.target) |
329 | + [source] = self.factory.makeGitRefs(source_repo) |
330 | + [target] = self.factory.makeGitRefs(target_repo) |
331 | + mp = self.factory.makeBranchMergeProposalForGit( |
332 | + source_ref=source, target_ref=target) |
333 | + self.assertEqual([], mp.getGitRefCopyOperations()) |
334 | + |
335 | + def test_getGitRefCopyOperations_private_source_same_repo(self): |
336 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
337 | + login_admin() |
338 | + repo = self.factory.makeGitRepository( |
339 | + information_type=InformationType.PRIVATESECURITY) |
340 | + [source, target] = self.factory.makeGitRefs( |
341 | + repo, ['refs/heads/bugfix', 'refs/heads/master']) |
342 | + mp = self.factory.makeBranchMergeProposalForGit( |
343 | + source_ref=source, target_ref=target) |
344 | + operations = mp.getGitRefCopyOperations() |
345 | + self.assertEqual(1, len(operations)) |
346 | + self.assertThat(operations[0], MatchesStructure( |
347 | + source_ref=Equals(mp.source_git_commit_sha1), |
348 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
349 | + target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp)) |
350 | + )) |
351 | + |
352 | + def test_getGitRefCopyOperations_private_source_same_owner(self): |
353 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
354 | + login_admin() |
355 | + source_repo = self.factory.makeGitRepository( |
356 | + information_type=InformationType.PRIVATESECURITY) |
357 | + target_repo = self.factory.makeGitRepository( |
358 | + target=source_repo.target, owner=source_repo.owner) |
359 | + [source] = self.factory.makeGitRefs(source_repo) |
360 | + [target] = self.factory.makeGitRefs(target_repo) |
361 | + mp = self.factory.makeBranchMergeProposalForGit( |
362 | + source_ref=source, target_ref=target) |
363 | + operations = mp.getGitRefCopyOperations() |
364 | + self.assertEqual(1, len(operations)) |
365 | + self.assertThat(operations[0], MatchesStructure( |
366 | + source_ref=Equals(mp.source_git_commit_sha1), |
367 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
368 | + target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp)) |
369 | + )) |
370 | + |
371 | + def test_syncGitVirtualRefs(self): |
372 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
373 | + login_admin() |
374 | + login_admin() |
375 | + source_repo = self.factory.makeGitRepository() |
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.syncGitVirtualRefs should have been triggered by event. |
383 | + # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created. |
384 | + self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
385 | + args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
386 | + self.assertEquals({}, kwargs) |
387 | + self.assertEqual(args[0], source_repo.getInternalPath()) |
388 | + self.assertEqual(1, len(args[1])) |
389 | + self.assertThat(args[1][0], MatchesStructure( |
390 | + source_ref=Equals(mp.source_git_commit_sha1), |
391 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
392 | + target_ref=Equals("refs/merge/%s/head" % mp.id), |
393 | + )) |
394 | + |
395 | + self.assertEqual(0, self.hosting_fixture.deleteRefs.call_count) |
396 | + |
397 | + def test_syncGitVirtualRefs_private_source_deletes_ref(self): |
398 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
399 | + login_admin() |
400 | + source_repo = self.factory.makeGitRepository( |
401 | + information_type=InformationType.PRIVATESECURITY) |
402 | + target_repo = self.factory.makeGitRepository(target=source_repo.target) |
403 | + [source] = self.factory.makeGitRefs(source_repo) |
404 | + [target] = self.factory.makeGitRefs(target_repo) |
405 | + mp = self.factory.makeBranchMergeProposalForGit( |
406 | + source_ref=source, target_ref=target) |
407 | + |
408 | + # mp.syncGitVirtualRefs should have been triggered by event. |
409 | + # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created. |
410 | + self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
411 | + self.assertEqual(1, self.hosting_fixture.deleteRefs.call_count) |
412 | + args, kwargs = self.hosting_fixture.deleteRefs.calls[0] |
413 | + self.assertEqual({}, kwargs) |
414 | + self.assertEqual( |
415 | + ([(target_repo.getInternalPath(), |
416 | + "refs/merge/%s/head" % mp.id)], ), |
417 | + args) |
418 | + |
419 | + |
420 | class TestBranchMergeProposalTransitions(TestCaseWithFactory): |
421 | """Test the state transitions of branch merge proposals.""" |
422 | |
423 | @@ -1185,6 +1321,10 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory): |
424 | def test_delete_triggers_webhooks(self): |
425 | # When an existing merge proposal is deleted, any relevant webhooks |
426 | # are triggered. |
427 | + self.useFixture(FeatureFixture({ |
428 | + GIT_CREATE_MP_VIRTUAL_REF: "on", |
429 | + BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG: "on"})) |
430 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
431 | logger = self.useFixture(FakeLogger()) |
432 | source = self.makeBranch() |
433 | target = self.makeBranch(same_target_as=source) |
434 | @@ -1205,8 +1345,11 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory): |
435 | old=MatchesDict(self.getExpectedPayload(proposal, redact=True))) |
436 | proposal.deleteProposal() |
437 | delivery = hook.deliveries.one() |
438 | + self.assertIsNotNone(delivery) |
439 | self.assertCorrectDelivery(expected_payload, hook, delivery) |
440 | self.assertCorrectLogging(expected_redacted_payload, hook, logger) |
441 | + self.assertEqual( |
442 | + 1 if self.git else 0, hosting_fixture.deleteRefs.call_count) |
443 | |
444 | |
445 | class TestGetAddress(TestCaseWithFactory): |
446 | @@ -1507,6 +1650,18 @@ class TestBranchMergeProposalDeletion(TestCaseWithFactory): |
447 | self.assertRaises( |
448 | SQLObjectNotFound, BranchMergeProposalJob.get, job_id) |
449 | |
450 | + def test_deleteProposal_for_git_removes_virtual_ref(self): |
451 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
452 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
453 | + proposal = self.factory.makeBranchMergeProposalForGit() |
454 | + proposal.deleteProposal() |
455 | + |
456 | + self.assertEqual(1, hosting_fixture.deleteRefs.call_count) |
457 | + args = hosting_fixture.deleteRefs.calls[0] |
458 | + self.assertEqual(( |
459 | + (([(proposal.target_git_repository.getInternalPath(), |
460 | + 'refs/merge/%s/head' % proposal.id)]), ), {}), args) |
461 | + |
462 | |
463 | class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory): |
464 | |
465 | diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py |
466 | index 23d6063..5ca77d3 100644 |
467 | --- a/lib/lp/code/model/tests/test_githosting.py |
468 | +++ b/lib/lp/code/model/tests/test_githosting.py |
469 | @@ -42,7 +42,6 @@ from lp.code.errors import ( |
470 | GitRepositoryDeletionFault, |
471 | GitRepositoryScanFault, |
472 | GitTargetError, |
473 | - NoSuchGitReference, |
474 | ) |
475 | from lp.code.interfaces.githosting import IGitHostingClient |
476 | from lp.code.model.githosting import RefCopyOperation |
477 | diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py |
478 | index b83f9da..53d9d57 100644 |
479 | --- a/lib/lp/code/model/tests/test_gitrepository.py |
480 | +++ b/lib/lp/code/model/tests/test_gitrepository.py |
481 | @@ -97,6 +97,7 @@ from lp.code.interfaces.gitnamespace import ( |
482 | IGitNamespaceSet, |
483 | ) |
484 | from lp.code.interfaces.gitrepository import ( |
485 | + GIT_CREATE_MP_VIRTUAL_REF, |
486 | IGitRepository, |
487 | IGitRepositorySet, |
488 | IGitRepositoryView, |
489 | @@ -783,6 +784,7 @@ class TestGitRepositoryDeletion(TestCaseWithFactory): |
490 | # Make sure that the tests all flush the database changes. |
491 | self.addCleanup(Store.of(self.repository).flush) |
492 | login_person(self.user) |
493 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
494 | |
495 | def test_deletable(self): |
496 | # A newly created repository can be deleted without any problems. |
497 | @@ -824,7 +826,6 @@ class TestGitRepositoryDeletion(TestCaseWithFactory): |
498 | |
499 | def test_code_import_does_not_disable_deletion(self): |
500 | # A repository that has an attached code import can be deleted. |
501 | - self.useFixture(GitHostingFixture()) |
502 | code_import = self.factory.makeCodeImport( |
503 | target_rcs_type=TargetRevisionControlSystems.GIT) |
504 | repository = code_import.git_repository |
505 | @@ -984,6 +985,7 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
506 | # unsubscribe the repository owner here. |
507 | self.repository.unsubscribe( |
508 | self.repository.owner, self.repository.owner) |
509 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
510 | |
511 | def test_plain_repository(self): |
512 | # A fresh repository has no deletion requirements. |
513 | @@ -1115,7 +1117,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
514 | |
515 | def test_code_import_requirements(self): |
516 | # Code imports are not included explicitly in deletion requirements. |
517 | - self.useFixture(GitHostingFixture()) |
518 | code_import = self.factory.makeCodeImport( |
519 | target_rcs_type=TargetRevisionControlSystems.GIT) |
520 | # Remove the implicit repository subscription first. |
521 | @@ -1126,7 +1127,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
522 | |
523 | def test_code_import_deletion(self): |
524 | # break_references allows deleting a code import repository. |
525 | - self.useFixture(GitHostingFixture()) |
526 | code_import = self.factory.makeCodeImport( |
527 | target_rcs_type=TargetRevisionControlSystems.GIT) |
528 | code_import_id = code_import.id |
529 | @@ -1182,7 +1182,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
530 | |
531 | def test_DeleteCodeImport(self): |
532 | # DeleteCodeImport.__call__ must delete the CodeImport. |
533 | - self.useFixture(GitHostingFixture()) |
534 | code_import = self.factory.makeCodeImport( |
535 | target_rcs_type=TargetRevisionControlSystems.GIT) |
536 | code_import_id = code_import.id |
537 | @@ -1521,6 +1520,10 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
538 | |
539 | layer = DatabaseFunctionalLayer |
540 | |
541 | + def setUp(self): |
542 | + super(TestGitRepositoryRefs, self).setUp() |
543 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
544 | + |
545 | def test__convertRefInfo(self): |
546 | # _convertRefInfo converts a valid info dictionary. |
547 | sha1 = six.ensure_text(hashlib.sha1("").hexdigest()) |
548 | @@ -1791,18 +1794,17 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
549 | # planRefChanges excludes some ref prefixes by default, and can be |
550 | # configured otherwise. |
551 | repository = self.factory.makeGitRepository() |
552 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
553 | repository.planRefChanges("dummy") |
554 | self.assertEqual( |
555 | [{"exclude_prefixes": ["refs/changes/"]}], |
556 | - hosting_fixture.getRefs.extract_kwargs()) |
557 | - hosting_fixture.getRefs.calls = [] |
558 | + self.hosting_fixture.getRefs.extract_kwargs()) |
559 | + self.hosting_fixture.getRefs.calls = [] |
560 | self.pushConfig( |
561 | "codehosting", git_exclude_ref_prefixes="refs/changes/ refs/pull/") |
562 | repository.planRefChanges("dummy") |
563 | self.assertEqual( |
564 | [{"exclude_prefixes": ["refs/changes/", "refs/pull/"]}], |
565 | - hosting_fixture.getRefs.extract_kwargs()) |
566 | + self.hosting_fixture.getRefs.extract_kwargs()) |
567 | |
568 | def test_fetchRefCommits(self): |
569 | # fetchRefCommits fetches detailed tip commit metadata for the |
570 | @@ -1875,9 +1877,8 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
571 | def test_fetchRefCommits_empty(self): |
572 | # If given an empty refs dictionary, fetchRefCommits returns early |
573 | # without contacting the hosting service. |
574 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
575 | GitRepository.fetchRefCommits("dummy", {}) |
576 | - self.assertEqual([], hosting_fixture.getCommits.calls) |
577 | + self.assertEqual([], self.hosting_fixture.getCommits.calls) |
578 | |
579 | def test_synchroniseRefs(self): |
580 | # synchroniseRefs copes with synchronising a repository where some |
581 | @@ -1920,7 +1921,6 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
582 | self.assertThat(repository.refs, MatchesSetwise(*matchers)) |
583 | |
584 | def test_set_default_branch(self): |
585 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
586 | repository = self.factory.makeGitRepository() |
587 | self.factory.makeGitRefs( |
588 | repository=repository, |
589 | @@ -1931,22 +1931,20 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
590 | self.assertEqual( |
591 | [((repository.getInternalPath(),), |
592 | {"default_branch": "refs/heads/new"})], |
593 | - hosting_fixture.setProperties.calls) |
594 | + self.hosting_fixture.setProperties.calls) |
595 | self.assertEqual("refs/heads/new", repository.default_branch) |
596 | |
597 | def test_set_default_branch_unchanged(self): |
598 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
599 | repository = self.factory.makeGitRepository() |
600 | self.factory.makeGitRefs( |
601 | repository=repository, paths=["refs/heads/master"]) |
602 | removeSecurityProxy(repository)._default_branch = "refs/heads/master" |
603 | with person_logged_in(repository.owner): |
604 | repository.default_branch = "master" |
605 | - self.assertEqual([], hosting_fixture.setProperties.calls) |
606 | + self.assertEqual([], self.hosting_fixture.setProperties.calls) |
607 | self.assertEqual("refs/heads/master", repository.default_branch) |
608 | |
609 | def test_set_default_branch_imported(self): |
610 | - hosting_fixture = self.useFixture(GitHostingFixture()) |
611 | repository = self.factory.makeGitRepository( |
612 | repository_type=GitRepositoryType.IMPORTED) |
613 | self.factory.makeGitRefs( |
614 | @@ -1959,7 +1957,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory): |
615 | "Cannot modify non-hosted Git repository %s." % |
616 | repository.display_name, |
617 | setattr, repository, "default_branch", "new") |
618 | - self.assertEqual([], hosting_fixture.setProperties.calls) |
619 | + self.assertEqual([], self.hosting_fixture.setProperties.calls) |
620 | self.assertEqual("refs/heads/master", repository.default_branch) |
621 | |
622 | def test_exception_unset_default_branch(self): |
623 | @@ -2580,18 +2578,59 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory): |
624 | |
625 | layer = DatabaseFunctionalLayer |
626 | |
627 | - def test_schedules_diff_updates(self): |
628 | + def setUp(self): |
629 | + super(TestGitRepositoryUpdateLandingTargets, self).setUp() |
630 | + self.hosting_fixture = self.useFixture(GitHostingFixture()) |
631 | + |
632 | + def assertSchedulesDiffUpdate(self, with_mp_virtual_ref): |
633 | """Create jobs for all merge proposals.""" |
634 | bmp1 = self.factory.makeBranchMergeProposalForGit() |
635 | bmp2 = self.factory.makeBranchMergeProposalForGit( |
636 | source_ref=bmp1.source_git_ref) |
637 | - jobs = bmp1.source_git_repository.updateLandingTargets( |
638 | + |
639 | + # Only enable this virtual ref here, since |
640 | + # self.factory.makeBranchMergeProposalForGit also tries to create |
641 | + # the virtual refs. |
642 | + if with_mp_virtual_ref: |
643 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
644 | + else: |
645 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: ""})) |
646 | + jobs, ref_copies = bmp1.source_git_repository.updateLandingTargets( |
647 | [bmp1.source_git_path]) |
648 | self.assertEqual(2, len(jobs)) |
649 | bmps_to_update = [ |
650 | removeSecurityProxy(job).branch_merge_proposal for job in jobs] |
651 | self.assertContentEqual([bmp1, bmp2], bmps_to_update) |
652 | |
653 | + if not with_mp_virtual_ref: |
654 | + self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
655 | + else: |
656 | + self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
657 | + args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
658 | + self.assertEqual({}, kwargs) |
659 | + self.assertEqual(2, len(args)) |
660 | + path, operations = args |
661 | + self.assertEqual( |
662 | + path, bmp1.source_git_repository.getInternalPath()) |
663 | + self.assertThat(operations[0], MatchesStructure( |
664 | + source_ref=Equals(bmp1.source_git_commit_sha1), |
665 | + target_repo=Equals( |
666 | + bmp1.target_git_repository.getInternalPath()), |
667 | + target_ref=Equals("refs/merge/%s/head" % bmp1.id), |
668 | + )) |
669 | + self.assertThat(operations[1], MatchesStructure( |
670 | + source_ref=Equals(bmp2.source_git_commit_sha1), |
671 | + target_repo=Equals( |
672 | + bmp2.target_git_repository.getInternalPath()), |
673 | + target_ref=Equals("refs/merge/%s/head" % bmp2.id), |
674 | + )) |
675 | + |
676 | + def test_schedules_diff_updates_with_mp_virtual_ref(self): |
677 | + self.assertSchedulesDiffUpdate(with_mp_virtual_ref=True) |
678 | + |
679 | + def test_schedules_diff_updates_without_mp_virtual_ref(self): |
680 | + self.assertSchedulesDiffUpdate(with_mp_virtual_ref=False) |
681 | + |
682 | def test_ignores_final(self): |
683 | """Diffs for proposals in final states aren't updated.""" |
684 | [source_ref] = self.factory.makeGitRefs() |
685 | @@ -2603,8 +2642,15 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory): |
686 | for bmp in source_ref.landing_targets: |
687 | if bmp.queue_status not in FINAL_STATES: |
688 | removeSecurityProxy(bmp).deleteProposal() |
689 | - jobs = source_ref.repository.updateLandingTargets([source_ref.path]) |
690 | + |
691 | + # Enable the feature here, since factory.makeBranchMergeProposalForGit |
692 | + # would also trigger the copy refs call. |
693 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
694 | + jobs, ref_copies = source_ref.repository.updateLandingTargets( |
695 | + [source_ref.path]) |
696 | self.assertEqual(0, len(jobs)) |
697 | + self.assertEqual(0, len(ref_copies)) |
698 | + self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
699 | |
700 | |
701 | class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): |
702 | diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py |
703 | index a214c8f..a34ca2a 100644 |
704 | --- a/lib/lp/code/subscribers/branchmergeproposal.py |
705 | +++ b/lib/lp/code/subscribers/branchmergeproposal.py |
706 | @@ -1,4 +1,4 @@ |
707 | -# Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
708 | +# Copyright 2010-2020 Canonical Ltd. This software is licensed under the |
709 | # GNU Affero General Public License version 3 (see the file LICENSE). |
710 | |
711 | """Event subscribers for branch merge proposals.""" |
712 | @@ -63,9 +63,13 @@ def _trigger_webhook(merge_proposal, payload): |
713 | def merge_proposal_created(merge_proposal, event): |
714 | """A new merge proposal has been created. |
715 | |
716 | - Create a job to update the diff for the merge proposal; trigger webhooks. |
717 | + Create a job to update the diff for the merge proposal; trigger webhooks |
718 | + and copy virtual refs as needed. |
719 | """ |
720 | getUtility(IUpdatePreviewDiffJobSource).create(merge_proposal) |
721 | + |
722 | + merge_proposal.syncGitVirtualRefs() |
723 | + |
724 | if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG): |
725 | payload = { |
726 | "action": "created", |
727 | @@ -149,3 +153,5 @@ def merge_proposal_deleted(merge_proposal, event): |
728 | "old": _compose_merge_proposal_webhook_payload(merge_proposal), |
729 | } |
730 | _trigger_webhook(merge_proposal, payload) |
731 | + |
732 | + merge_proposal.syncGitVirtualRefs(force_delete=True) |
733 | diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py |
734 | index 4ef843d..e0ab275 100644 |
735 | --- a/lib/lp/code/tests/helpers.py |
736 | +++ b/lib/lp/code/tests/helpers.py |
737 | @@ -369,6 +369,8 @@ class GitHostingFixture(fixtures.Fixture): |
738 | self.getBlob = FakeMethod(result=blob) |
739 | self.delete = FakeMethod() |
740 | self.disable_memcache = disable_memcache |
741 | + self.copyRefs = FakeMethod() |
742 | + self.deleteRefs = FakeMethod() |
743 | |
744 | def _setUp(self): |
745 | self.useFixture(ZopeUtilityFixture(self, IGitHostingClient)) |
How does this interact with privacy?