Merge lp:~cjwatson/launchpad/git-mp-collection into lp:launchpad
- git-mp-collection
- Merge into devel
Proposed by
Colin Watson
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 17455 | ||||
Proposed branch: | lp:~cjwatson/launchpad/git-mp-collection | ||||
Merge into: | lp:launchpad | ||||
Prerequisite: | lp:~cjwatson/launchpad/git-mp-create | ||||
Diff against target: |
913 lines (+606/-36) 5 files modified
lib/lp/code/interfaces/gitcollection.py (+45/-0) lib/lp/code/model/branchmergeproposal.py (+17/-5) lib/lp/code/model/gitcollection.py (+267/-26) lib/lp/code/model/gitrepository.py (+4/-4) lib/lp/code/model/tests/test_gitcollection.py (+273/-1) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/git-mp-collection | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+257145@code.launchpad.net |
Commit message
Add merge proposal support to GitCollection.
Description of the change
Add merge proposal support to GitCollection. This will be needed to support various views shortly.
The non-trivial database handling in GitCollection consists almost entirely of undoing simplifications I applied to it relative to BranchCollection when we didn't need merge proposal support. Now that we do, we need all the asymmetric filter stuff as well.
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/code/interfaces/gitcollection.py' |
2 | --- lib/lp/code/interfaces/gitcollection.py 2015-04-15 18:34:25 +0000 |
3 | +++ lib/lp/code/interfaces/gitcollection.py 2015-04-24 11:37:11 +0000 |
4 | @@ -69,6 +69,42 @@ |
5 | def getRepositoryIds(): |
6 | """Return a result set of all repository ids in this collection.""" |
7 | |
8 | + # XXX cjwatson 2015-04-16: Add something like for_repositories or |
9 | + # for_refs once we know exactly what we need. |
10 | + def getMergeProposals(statuses=None, target_repository=None, |
11 | + target_path=None, eager_load=False): |
12 | + """Return a result set of merge proposals for the repositories in |
13 | + this collection. |
14 | + |
15 | + :param statuses: If specified, only return merge proposals with these |
16 | + statuses. If not, return all merge proposals. |
17 | + :param target_repository: If specified, only return merge proposals |
18 | + that target the specified repository. |
19 | + :param target_path: If specified, only return merge proposals that |
20 | + target the specified path. |
21 | + :param eager_load: If True, preloads all the related information for |
22 | + merge proposals like PreviewDiffs and GitRepositories. |
23 | + """ |
24 | + |
25 | + def getMergeProposalsForPerson(person, status=None): |
26 | + """Proposals for `person`. |
27 | + |
28 | + Return the proposals for repositories owned by `person` or where |
29 | + `person` is reviewing or been asked to review. |
30 | + """ |
31 | + |
32 | + def getMergeProposalsForReviewer(reviewer, status=None): |
33 | + """Return a result set of merge proposals for the given reviewer. |
34 | + |
35 | + That is, all merge proposals that 'reviewer' has voted on or has |
36 | + been invited to vote on. |
37 | + |
38 | + :param reviewer: An `IPerson` who is a reviewer. |
39 | + :param status: An iterable of queue_status of the proposals to |
40 | + return. If None is specified, all the proposals of all possible |
41 | + states are returned. |
42 | + """ |
43 | + |
44 | def getTeamsWithRepositories(person): |
45 | """Return the teams that person is a member of that have |
46 | repositories.""" |
47 | @@ -117,6 +153,15 @@ |
48 | """Restrict the collection to repositories subscribed to by |
49 | 'person'.""" |
50 | |
51 | + def targetedBy(person, since=None): |
52 | + """Restrict the collection to repositories targeted by person. |
53 | + |
54 | + A repository is targeted by a person if that person has registered a |
55 | + merge proposal with a reference in that repository as the target. |
56 | + |
57 | + :param since: If supplied, ignore merge proposals before this date. |
58 | + """ |
59 | + |
60 | def visibleByUser(person): |
61 | """Restrict the collection to repositories that person is allowed to |
62 | see.""" |
63 | |
64 | === modified file 'lib/lp/code/model/branchmergeproposal.py' |
65 | --- lib/lp/code/model/branchmergeproposal.py 2015-04-22 14:52:10 +0000 |
66 | +++ lib/lp/code/model/branchmergeproposal.py 2015-04-24 11:37:11 +0000 |
67 | @@ -1054,16 +1054,19 @@ |
68 | # Circular imports. |
69 | from lp.code.model.branch import Branch |
70 | from lp.code.model.branchcollection import GenericBranchCollection |
71 | + from lp.code.model.gitcollection import GenericGitCollection |
72 | + from lp.code.model.gitrepository import GitRepository |
73 | |
74 | ids = set() |
75 | source_branch_ids = set() |
76 | + source_git_repository_ids = set() |
77 | person_ids = set() |
78 | for mp in branch_merge_proposals: |
79 | ids.add(mp.id) |
80 | if mp.source_branchID is not None: |
81 | source_branch_ids.add(mp.source_branchID) |
82 | - # XXX cjwatson 2015-04-22: Implement for Git once GitCollection |
83 | - # supports MPs. |
84 | + if mp.source_git_repositoryID is not None: |
85 | + source_git_repository_ids.add(mp.source_git_repositoryID) |
86 | person_ids.add(mp.registrantID) |
87 | person_ids.add(mp.merge_reporterID) |
88 | |
89 | @@ -1071,11 +1074,15 @@ |
90 | Branch, branch_merge_proposals, ( |
91 | "target_branchID", "prerequisite_branchID", |
92 | "source_branchID")) |
93 | + repositories = load_related( |
94 | + GitRepository, branch_merge_proposals, ( |
95 | + "target_git_repositoryID", "prerequisite_git_repositoryID", |
96 | + "source_git_repositoryID")) |
97 | # The stacked on branches are used to check branch visibility. |
98 | GenericBranchCollection.preloadVisibleStackedOnBranches( |
99 | branches, user) |
100 | |
101 | - if len(branches) == 0: |
102 | + if len(branches) == 0 and len(repositories) == 0: |
103 | return |
104 | |
105 | # Pre-load PreviewDiffs and Diffs. |
106 | @@ -1090,17 +1097,22 @@ |
107 | cache = get_property_cache(previewdiff.branch_merge_proposal) |
108 | cache.preview_diff = previewdiff |
109 | |
110 | - # Add source branch owners' to the list of pre-loaded persons. |
111 | + # Add source branch/repository owners' to the list of pre-loaded |
112 | + # persons. |
113 | person_ids.update( |
114 | branch.ownerID for branch in branches |
115 | if branch.id in source_branch_ids) |
116 | + person_ids.update( |
117 | + repository.owner_id for repository in repositories |
118 | + if repository.id in source_git_repository_ids) |
119 | |
120 | # Pre-load Person and ValidPersonCache. |
121 | list(getUtility(IPersonSet).getPrecachedPersonsFromIDs( |
122 | person_ids, need_validity=True)) |
123 | |
124 | - # Pre-load branches' data. |
125 | + # Pre-load branches'/repositories' data. |
126 | GenericBranchCollection.preloadDataForBranches(branches) |
127 | + GenericGitCollection.preloadDataForRepositories(repositories) |
128 | |
129 | |
130 | class BranchMergeProposalGetter: |
131 | |
132 | === modified file 'lib/lp/code/model/gitcollection.py' |
133 | --- lib/lp/code/model/gitcollection.py 2015-04-15 18:34:25 +0000 |
134 | +++ lib/lp/code/model/gitcollection.py 2015-04-24 11:37:11 +0000 |
135 | @@ -8,16 +8,25 @@ |
136 | 'GenericGitCollection', |
137 | ] |
138 | |
139 | +from functools import partial |
140 | + |
141 | from lazr.uri import ( |
142 | InvalidURIError, |
143 | URI, |
144 | ) |
145 | from storm.expr import ( |
146 | + And, |
147 | Count, |
148 | + Desc, |
149 | In, |
150 | Join, |
151 | + LeftJoin, |
152 | Select, |
153 | + SQL, |
154 | + With, |
155 | ) |
156 | +from storm.info import ClassAlias |
157 | +from storm.store import EmptyResultSet |
158 | from zope.component import getUtility |
159 | from zope.interface import implements |
160 | |
161 | @@ -31,14 +40,19 @@ |
162 | from lp.code.interfaces.gitrepository import ( |
163 | user_has_special_git_repository_access, |
164 | ) |
165 | +from lp.code.model.branchmergeproposal import BranchMergeProposal |
166 | +from lp.code.model.codereviewcomment import CodeReviewComment |
167 | +from lp.code.model.codereviewvote import CodeReviewVoteReference |
168 | from lp.code.model.gitrepository import ( |
169 | GitRepository, |
170 | get_git_repository_privacy_filter, |
171 | ) |
172 | from lp.code.model.gitsubscription import GitSubscription |
173 | from lp.registry.enums import EXCLUSIVE_TEAM_POLICY |
174 | +from lp.registry.model.distribution import Distribution |
175 | from lp.registry.model.person import Person |
176 | from lp.registry.model.product import Product |
177 | +from lp.registry.model.sourcepackagename import SourcePackageName |
178 | from lp.registry.model.teammembership import TeamParticipation |
179 | from lp.services.database.bulk import load_related |
180 | from lp.services.database.decoratedresultset import DecoratedResultSet |
181 | @@ -51,7 +65,8 @@ |
182 | |
183 | implements(IGitCollection) |
184 | |
185 | - def __init__(self, store=None, filter_expressions=None, tables=None): |
186 | + def __init__(self, store=None, filter_expressions=None, tables=None, |
187 | + asymmetric_filter_expressions=None, asymmetric_tables=None): |
188 | """Construct a `GenericGitCollection`. |
189 | |
190 | :param store: The store to look in for repositories. If not |
191 | @@ -63,6 +78,10 @@ |
192 | :param tables: A dict of Storm tables to the Join expression. If an |
193 | expression in filter_expressions refers to a table, then that |
194 | table *must* be in this list. |
195 | + :param asymmetric_filter_expressions: As per filter_expressions but |
196 | + only applies to one side of reflexive joins. |
197 | + :param asymmetric_tables: As per tables, for |
198 | + asymmetric_filter_expressions. |
199 | """ |
200 | self._store = store |
201 | if filter_expressions is None: |
202 | @@ -71,6 +90,13 @@ |
203 | if tables is None: |
204 | tables = {} |
205 | self._tables = tables |
206 | + if asymmetric_filter_expressions is None: |
207 | + asymmetric_filter_expressions = [] |
208 | + self._asymmetric_filter_expressions = list( |
209 | + asymmetric_filter_expressions) |
210 | + if asymmetric_tables is None: |
211 | + asymmetric_tables = {} |
212 | + self._asymmetric_tables = asymmetric_tables |
213 | self._user = None |
214 | |
215 | def count(self): |
216 | @@ -102,8 +128,13 @@ |
217 | else: |
218 | return self._store |
219 | |
220 | - def _filterBy(self, expressions, table=None, join=None): |
221 | - """Return a subset of this collection, filtered by 'expressions'.""" |
222 | + def _filterBy(self, expressions, table=None, join=None, symmetric=True): |
223 | + """Return a subset of this collection, filtered by 'expressions'. |
224 | + |
225 | + :param symmetric: If True, this filter will apply to both sides of |
226 | + merge proposal lookups and any other lookups that join |
227 | + GitRepository back onto GitRepository. |
228 | + """ |
229 | # NOTE: JonathanLange 2009-02-17: We might be able to avoid the need |
230 | # for explicit 'tables' by harnessing Storm's table inference system. |
231 | # See http://paste.ubuntu.com/118711/ for one way to do that. |
232 | @@ -112,10 +143,23 @@ |
233 | if expressions is None: |
234 | expressions = [] |
235 | tables = self._tables.copy() |
236 | + asymmetric_tables = self._asymmetric_tables.copy() |
237 | + if symmetric: |
238 | + if table is not None: |
239 | + tables[table] = join |
240 | + symmetric_expr = self._filter_expressions + expressions |
241 | + asymmetric_expr = list(self._asymmetric_filter_expressions) |
242 | + else: |
243 | + if table is not None: |
244 | + asymmetric_tables[table] = join |
245 | + symmetric_expr = list(self._filter_expressions) |
246 | + asymmetric_expr = ( |
247 | + self._asymmetric_filter_expressions + expressions) |
248 | if table is not None: |
249 | tables[table] = join |
250 | return self.__class__( |
251 | - self.store, self._filter_expressions + expressions, tables) |
252 | + self.store, symmetric_expr, tables, |
253 | + asymmetric_expr, asymmetric_tables) |
254 | |
255 | def _getRepositorySelect(self, columns=(GitRepository.id,)): |
256 | """Return a Storm 'Select' for columns in this collection.""" |
257 | @@ -126,15 +170,25 @@ |
258 | def _getRepositoryExpressions(self): |
259 | """Return the where expressions for this collection.""" |
260 | return (self._filter_expressions + |
261 | + self._asymmetric_filter_expressions + |
262 | self._getRepositoryVisibilityExpression()) |
263 | |
264 | - def _getRepositoryVisibilityExpression(self): |
265 | + def _getRepositoryVisibilityExpression(self, repository_class=None): |
266 | """Return the where clauses for visibility.""" |
267 | return [] |
268 | |
269 | + @staticmethod |
270 | + def preloadDataForRepositories(repositories): |
271 | + """Preload repositories' cached associated targets.""" |
272 | + load_related(Distribution, repositories, ['distribution_id']) |
273 | + load_related(SourcePackageName, repositories, ['sourcepackagename_id']) |
274 | + load_related(Product, repositories, ['project_id']) |
275 | + |
276 | def getRepositories(self, find_expr=GitRepository, eager_load=False): |
277 | """See `IGitCollection`.""" |
278 | - tables = [GitRepository] + list(set(self._tables.values())) |
279 | + all_tables = set( |
280 | + self._tables.values() + self._asymmetric_tables.values()) |
281 | + tables = [GitRepository] + list(all_tables) |
282 | expressions = self._getRepositoryExpressions() |
283 | resultset = self.store.using(*tables).find(find_expr, *expressions) |
284 | |
285 | @@ -165,6 +219,148 @@ |
286 | return self.getRepositories( |
287 | find_expr=GitRepository.id).get_plain_result_set() |
288 | |
289 | + def getMergeProposals(self, statuses=None, target_repository=None, |
290 | + target_path=None, merged_revision_ids=None, |
291 | + eager_load=False): |
292 | + """See `IGitCollection`.""" |
293 | + if merged_revision_ids is not None and not merged_revision_ids: |
294 | + # We have an empty revision list, so we can shortcut. |
295 | + return EmptyResultSet() |
296 | + elif (self._asymmetric_filter_expressions or |
297 | + target_repository is not None or |
298 | + target_path is not None or |
299 | + merged_revision_ids is not None): |
300 | + return self._naiveGetMergeProposals( |
301 | + statuses, target_repository, target_path, merged_revision_ids, |
302 | + eager_load=eager_load) |
303 | + else: |
304 | + # When examining merge proposals in a scope, this is a moderately |
305 | + # effective set of constrained queries. It is not effective when |
306 | + # unscoped or when tight constraints on repositories are present. |
307 | + return self._scopedGetMergeProposals( |
308 | + statuses, eager_load=eager_load) |
309 | + |
310 | + def _naiveGetMergeProposals(self, statuses=None, target_repository=None, |
311 | + target_path=None, merged_revision_ids=None, |
312 | + eager_load=False): |
313 | + Target = ClassAlias(GitRepository, "target") |
314 | + extra_tables = list(set( |
315 | + self._tables.values() + self._asymmetric_tables.values())) |
316 | + tables = [GitRepository] + extra_tables + [ |
317 | + Join(BranchMergeProposal, And( |
318 | + GitRepository.id == |
319 | + BranchMergeProposal.source_git_repositoryID, |
320 | + *(self._filter_expressions + |
321 | + self._asymmetric_filter_expressions))), |
322 | + Join(Target, |
323 | + Target.id == BranchMergeProposal.target_git_repositoryID), |
324 | + ] |
325 | + expressions = self._getRepositoryVisibilityExpression() |
326 | + expressions.extend(self._getRepositoryVisibilityExpression(Target)) |
327 | + if target_repository is not None: |
328 | + expressions.append( |
329 | + BranchMergeProposal.target_git_repository == target_repository) |
330 | + if target_path is not None: |
331 | + expressions.append( |
332 | + BranchMergeProposal.target_git_path == target_path) |
333 | + if merged_revision_ids is not None: |
334 | + expressions.append( |
335 | + BranchMergeProposal.merged_revision_id.is_in( |
336 | + merged_revision_ids)) |
337 | + if statuses is not None: |
338 | + expressions.append( |
339 | + BranchMergeProposal.queue_status.is_in(statuses)) |
340 | + resultset = self.store.using(*tables).find( |
341 | + BranchMergeProposal, *expressions) |
342 | + if not eager_load: |
343 | + return resultset |
344 | + else: |
345 | + loader = partial( |
346 | + BranchMergeProposal.preloadDataForBMPs, user=self._user) |
347 | + return DecoratedResultSet(resultset, pre_iter_hook=loader) |
348 | + |
349 | + def _scopedGetMergeProposals(self, statuses, eager_load=False): |
350 | + expressions = ( |
351 | + self._filter_expressions |
352 | + + self._getRepositoryVisibilityExpression()) |
353 | + with_expr = With( |
354 | + "candidate_repositories", |
355 | + Select( |
356 | + GitRepository.id, |
357 | + tables=[GitRepository] + self._tables.values(), |
358 | + where=And(*expressions) if expressions else True)) |
359 | + expressions = [SQL(""" |
360 | + source_git_repository IN |
361 | + (SELECT id FROM candidate_repositories) AND |
362 | + target_git_repository IN |
363 | + (SELECT id FROM candidate_repositories)""")] |
364 | + tables = [BranchMergeProposal] |
365 | + if self._asymmetric_filter_expressions: |
366 | + # Need to filter on GitRepository beyond the with constraints. |
367 | + expressions += self._asymmetric_filter_expressions |
368 | + expressions.append( |
369 | + BranchMergeProposal.source_git_repositoryID == |
370 | + GitRepository.id) |
371 | + tables.append(GitRepository) |
372 | + tables.extend(self._asymmetric_tables.values()) |
373 | + if statuses is not None: |
374 | + expressions.append( |
375 | + BranchMergeProposal.queue_status.is_in(statuses)) |
376 | + resultset = self.store.with_(with_expr).using(*tables).find( |
377 | + BranchMergeProposal, *expressions) |
378 | + if not eager_load: |
379 | + return resultset |
380 | + else: |
381 | + loader = partial( |
382 | + BranchMergeProposal.preloadDataForBMPs, user=self._user) |
383 | + return DecoratedResultSet(resultset, pre_iter_hook=loader) |
384 | + |
385 | + def getMergeProposalsForPerson(self, person, status=None, |
386 | + eager_load=False): |
387 | + """See `IGitCollection`.""" |
388 | + # We want to limit the proposals to those where the source repository |
389 | + # is limited by the defined collection. |
390 | + owned = self.ownedBy(person).getMergeProposals(status) |
391 | + reviewing = self.getMergeProposalsForReviewer(person, status) |
392 | + resultset = owned.union(reviewing) |
393 | + |
394 | + if not eager_load: |
395 | + return resultset |
396 | + else: |
397 | + loader = partial( |
398 | + BranchMergeProposal.preloadDataForBMPs, user=self._user) |
399 | + return DecoratedResultSet(resultset, pre_iter_hook=loader) |
400 | + |
401 | + def getMergeProposalsForReviewer(self, reviewer, status=None): |
402 | + """See `IGitCollection`.""" |
403 | + tables = [ |
404 | + BranchMergeProposal, |
405 | + Join(CodeReviewVoteReference, |
406 | + CodeReviewVoteReference.branch_merge_proposalID == \ |
407 | + BranchMergeProposal.id), |
408 | + LeftJoin(CodeReviewComment, |
409 | + CodeReviewVoteReference.commentID == CodeReviewComment.id)] |
410 | + |
411 | + expressions = [ |
412 | + CodeReviewVoteReference.reviewer == reviewer, |
413 | + BranchMergeProposal.source_git_repositoryID.is_in( |
414 | + self._getRepositorySelect())] |
415 | + visibility = self._getRepositoryVisibilityExpression() |
416 | + if visibility: |
417 | + expressions.append( |
418 | + BranchMergeProposal.target_git_repositoryID.is_in( |
419 | + Select(GitRepository.id, visibility))) |
420 | + if status is not None: |
421 | + expressions.append( |
422 | + BranchMergeProposal.queue_status.is_in(status)) |
423 | + proposals = self.store.using(*tables).find( |
424 | + BranchMergeProposal, *expressions) |
425 | + # Apply sorting here as we can't do it in the browser code. We need to |
426 | + # think carefully about the best places to do this, but not here nor |
427 | + # now. |
428 | + proposals.order_by(Desc(CodeReviewComment.vote)) |
429 | + return proposals |
430 | + |
431 | def getTeamsWithRepositories(self, person): |
432 | """See `IGitCollection`.""" |
433 | # This method doesn't entirely fit with the intent of the |
434 | @@ -221,18 +417,20 @@ |
435 | |
436 | def ownedBy(self, person): |
437 | """See `IGitCollection`.""" |
438 | - return self._filterBy([GitRepository.owner == person]) |
439 | + return self._filterBy([GitRepository.owner == person], symmetric=False) |
440 | |
441 | def ownedByTeamMember(self, person): |
442 | """See `IGitCollection`.""" |
443 | subquery = Select( |
444 | TeamParticipation.teamID, |
445 | where=TeamParticipation.personID == person.id) |
446 | - return self._filterBy([In(GitRepository.owner_id, subquery)]) |
447 | + return self._filterBy( |
448 | + [In(GitRepository.owner_id, subquery)], symmetric=False) |
449 | |
450 | def registeredBy(self, person): |
451 | """See `IGitCollection`.""" |
452 | - return self._filterBy([GitRepository.registrant == person]) |
453 | + return self._filterBy( |
454 | + [GitRepository.registrant == person], symmetric=False) |
455 | |
456 | def _getExactMatch(self, term): |
457 | # Look up the repository by its URL, which handles both shortcuts |
458 | @@ -274,7 +472,21 @@ |
459 | [GitSubscription.person == person], |
460 | table=GitSubscription, |
461 | join=Join(GitSubscription, |
462 | - GitSubscription.repository == GitRepository.id)) |
463 | + GitSubscription.repository == GitRepository.id), |
464 | + symmetric=False) |
465 | + |
466 | + def targetedBy(self, person, since=None): |
467 | + """See `IGitCollection`.""" |
468 | + clauses = [BranchMergeProposal.registrant == person] |
469 | + if since is not None: |
470 | + clauses.append(BranchMergeProposal.date_created >= since) |
471 | + return self._filterBy( |
472 | + clauses, |
473 | + table=BranchMergeProposal, |
474 | + join=Join( |
475 | + BranchMergeProposal, |
476 | + BranchMergeProposal.target_git_repository == GitRepository.id), |
477 | + symmetric=False) |
478 | |
479 | def visibleByUser(self, person): |
480 | """See `IGitCollection`.""" |
481 | @@ -283,33 +495,46 @@ |
482 | return self |
483 | if person is None: |
484 | return AnonymousGitCollection( |
485 | - self._store, self._filter_expressions, self._tables) |
486 | + self._store, self._filter_expressions, self._tables, |
487 | + self._asymmetric_filter_expressions, self._asymmetric_tables) |
488 | return VisibleGitCollection( |
489 | - person, self._store, self._filter_expressions, self._tables) |
490 | + person, self._store, self._filter_expressions, self._tables, |
491 | + self._asymmetric_filter_expressions, self._asymmetric_tables) |
492 | |
493 | def withIds(self, *repository_ids): |
494 | """See `IGitCollection`.""" |
495 | - return self._filterBy([GitRepository.id.is_in(repository_ids)]) |
496 | + return self._filterBy( |
497 | + [GitRepository.id.is_in(repository_ids)], symmetric=False) |
498 | |
499 | |
500 | class AnonymousGitCollection(GenericGitCollection): |
501 | """Repository collection that only shows public repositories.""" |
502 | |
503 | - def _getRepositoryVisibilityExpression(self): |
504 | + def _getRepositoryVisibilityExpression(self, |
505 | + repository_class=GitRepository): |
506 | """Return the where clauses for visibility.""" |
507 | - return get_git_repository_privacy_filter(None) |
508 | + return get_git_repository_privacy_filter( |
509 | + None, repository_class=repository_class) |
510 | |
511 | |
512 | class VisibleGitCollection(GenericGitCollection): |
513 | """A repository collection that has special logic for visibility.""" |
514 | |
515 | - def __init__(self, user, store=None, filter_expressions=None, tables=None): |
516 | + def __init__(self, user, store=None, filter_expressions=None, tables=None, |
517 | + asymmetric_filter_expressions=None, asymmetric_tables=None): |
518 | super(VisibleGitCollection, self).__init__( |
519 | - store=store, filter_expressions=filter_expressions, tables=tables) |
520 | + store=store, filter_expressions=filter_expressions, tables=tables, |
521 | + asymmetric_filter_expressions=asymmetric_filter_expressions, |
522 | + asymmetric_tables=asymmetric_tables) |
523 | self._user = user |
524 | |
525 | - def _filterBy(self, expressions, table=None, join=None): |
526 | - """Return a subset of this collection, filtered by 'expressions'.""" |
527 | + def _filterBy(self, expressions, table=None, join=None, symmetric=True): |
528 | + """Return a subset of this collection, filtered by 'expressions'. |
529 | + |
530 | + :param symmetric: If True this filter will apply to both sides of |
531 | + merge proposal lookups and any other lookups that join |
532 | + GitRepository back onto GitRepository. |
533 | + """ |
534 | # NOTE: JonathanLange 2009-02-17: We might be able to avoid the need |
535 | # for explicit 'tables' by harnessing Storm's table inference system. |
536 | # See http://paste.ubuntu.com/118711/ for one way to do that. |
537 | @@ -318,14 +543,30 @@ |
538 | if expressions is None: |
539 | expressions = [] |
540 | tables = self._tables.copy() |
541 | - if table is not None: |
542 | - tables[table] = join |
543 | + asymmetric_tables = self._asymmetric_tables.copy() |
544 | + if symmetric: |
545 | + if table is not None: |
546 | + tables[table] = join |
547 | + symmetric_expr = self._filter_expressions + expressions |
548 | + asymmetric_expr = list(self._asymmetric_filter_expressions) |
549 | + else: |
550 | + if table is not None: |
551 | + asymmetric_tables[table] = join |
552 | + symmetric_expr = list(self._filter_expressions) |
553 | + asymmetric_expr = self._asymmetric_filter_expressions + expressions |
554 | return self.__class__( |
555 | - self._user, self.store, self._filter_expressions + expressions) |
556 | - |
557 | - def _getRepositoryVisibilityExpression(self): |
558 | - """Return the where clauses for visibility.""" |
559 | - return get_git_repository_privacy_filter(self._user) |
560 | + self._user, self.store, symmetric_expr, tables, |
561 | + asymmetric_expr, asymmetric_tables) |
562 | + |
563 | + def _getRepositoryVisibilityExpression(self, |
564 | + repository_class=GitRepository): |
565 | + """Return the where clauses for visibility. |
566 | + |
567 | + :param repository_class: The GitRepository class to use - permits |
568 | + using ClassAliases. |
569 | + """ |
570 | + return get_git_repository_privacy_filter( |
571 | + self._user, repository_class=repository_class) |
572 | |
573 | def visibleByUser(self, person): |
574 | """See `IGitCollection`.""" |
575 | |
576 | === modified file 'lib/lp/code/model/gitrepository.py' |
577 | --- lib/lp/code/model/gitrepository.py 2015-04-22 16:11:40 +0000 |
578 | +++ lib/lp/code/model/gitrepository.py 2015-04-24 11:37:11 +0000 |
579 | @@ -850,8 +850,8 @@ |
580 | return [] |
581 | |
582 | |
583 | -def get_git_repository_privacy_filter(user): |
584 | - public_filter = GitRepository.information_type.is_in( |
585 | +def get_git_repository_privacy_filter(user, repository_class=GitRepository): |
586 | + public_filter = repository_class.information_type.is_in( |
587 | PUBLIC_INFORMATION_TYPES) |
588 | |
589 | if user is None: |
590 | @@ -859,7 +859,7 @@ |
591 | |
592 | artifact_grant_query = Coalesce( |
593 | ArrayIntersects( |
594 | - SQL("GitRepository.access_grants"), |
595 | + SQL("%s.access_grants" % repository_class.__storm_table__), |
596 | Select( |
597 | ArrayAgg(TeamParticipation.teamID), |
598 | tables=TeamParticipation, |
599 | @@ -868,7 +868,7 @@ |
600 | |
601 | policy_grant_query = Coalesce( |
602 | ArrayIntersects( |
603 | - Array(SQL("GitRepository.access_policy")), |
604 | + Array(SQL("%s.access_policy" % repository_class.__storm_table__)), |
605 | Select( |
606 | ArrayAgg(AccessPolicyGrant.policy_id), |
607 | tables=(AccessPolicyGrant, |
608 | |
609 | === modified file 'lib/lp/code/model/tests/test_gitcollection.py' |
610 | --- lib/lp/code/model/tests/test_gitcollection.py 2015-04-15 18:34:25 +0000 |
611 | +++ lib/lp/code/model/tests/test_gitcollection.py 2015-04-24 11:37:11 +0000 |
612 | @@ -5,7 +5,18 @@ |
613 | |
614 | __metaclass__ = type |
615 | |
616 | +from datetime import ( |
617 | + datetime, |
618 | + timedelta, |
619 | + ) |
620 | +from operator import attrgetter |
621 | + |
622 | +import pytz |
623 | from testtools.matchers import Equals |
624 | +from storm.store import ( |
625 | + EmptyResultSet, |
626 | + Store, |
627 | + ) |
628 | from zope.component import getUtility |
629 | from zope.security.proxy import removeSecurityProxy |
630 | |
631 | @@ -13,6 +24,7 @@ |
632 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
633 | from lp.app.interfaces.services import IService |
634 | from lp.code.enums import ( |
635 | + BranchMergeProposalStatus, |
636 | BranchSubscriptionDiffSize, |
637 | BranchSubscriptionNotificationLevel, |
638 | CodeReviewNotificationLevel, |
639 | @@ -42,7 +54,10 @@ |
640 | StormStatementRecorder, |
641 | TestCaseWithFactory, |
642 | ) |
643 | -from lp.testing.layers import DatabaseFunctionalLayer |
644 | +from lp.testing.layers import ( |
645 | + DatabaseFunctionalLayer, |
646 | + LaunchpadFunctionalLayer, |
647 | + ) |
648 | from lp.testing.matchers import HasQueryCount |
649 | |
650 | |
651 | @@ -406,6 +421,263 @@ |
652 | collection = self.all_repositories.subscribedBy(subscriber) |
653 | self.assertEqual([repository], list(collection.getRepositories())) |
654 | |
655 | + def test_targetedBy(self): |
656 | + # Only repositories that are merge targets are returned. |
657 | + [target_ref] = self.factory.makeGitRefs() |
658 | + registrant = self.factory.makePerson() |
659 | + self.factory.makeBranchMergeProposalForGit( |
660 | + target_ref=target_ref, registrant=registrant) |
661 | + # And another not registered by registrant. |
662 | + self.factory.makeBranchMergeProposalForGit() |
663 | + collection = self.all_repositories.targetedBy(registrant) |
664 | + self.assertEqual( |
665 | + [target_ref.repository], list(collection.getRepositories())) |
666 | + |
667 | + def test_targetedBy_since(self): |
668 | + # Ignore proposals created before 'since'. |
669 | + bmp = self.factory.makeBranchMergeProposalForGit() |
670 | + date_created = self.factory.getUniqueDate() |
671 | + removeSecurityProxy(bmp).date_created = date_created |
672 | + registrant = bmp.registrant |
673 | + repositories = self.all_repositories.targetedBy( |
674 | + registrant, since=date_created) |
675 | + self.assertEqual( |
676 | + [bmp.target_git_repository], list(repositories.getRepositories())) |
677 | + since = self.factory.getUniqueDate() |
678 | + repositories = self.all_repositories.targetedBy( |
679 | + registrant, since=since) |
680 | + self.assertEqual([], list(repositories.getRepositories())) |
681 | + |
682 | + |
683 | +class TestBranchMergeProposals(TestCaseWithFactory): |
684 | + |
685 | + layer = LaunchpadFunctionalLayer |
686 | + |
687 | + def setUp(self): |
688 | + TestCaseWithFactory.setUp(self) |
689 | + self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"})) |
690 | + self.all_repositories = getUtility(IAllGitRepositories) |
691 | + |
692 | + def test_empty_branch_merge_proposals(self): |
693 | + proposals = self.all_repositories.getMergeProposals() |
694 | + self.assertEqual([], list(proposals)) |
695 | + |
696 | + def test_empty_revisions_shortcut(self): |
697 | + # If you explicitly pass an empty collection of revision numbers, |
698 | + # the method shortcuts and gives you an empty result set. In this |
699 | + # way, merged_revnos=None (the default) has a very different behaviour |
700 | + # than merged_revnos=[]: the first is no restriction, while the second |
701 | + # excludes everything. |
702 | + self.factory.makeBranchMergeProposalForGit() |
703 | + proposals = self.all_repositories.getMergeProposals( |
704 | + merged_revision_ids=[]) |
705 | + self.assertEqual([], list(proposals)) |
706 | + self.assertIsInstance(proposals, EmptyResultSet) |
707 | + |
708 | + def test_some_branch_merge_proposals(self): |
709 | + mp = self.factory.makeBranchMergeProposalForGit() |
710 | + proposals = self.all_repositories.getMergeProposals() |
711 | + self.assertEqual([mp], list(proposals)) |
712 | + |
713 | + def test_just_owned_branch_merge_proposals(self): |
714 | + # If the collection only includes branches owned by a person, the |
715 | + # getMergeProposals() will only return merge proposals for source |
716 | + # branches that are owned by that person. |
717 | + person = self.factory.makePerson() |
718 | + project = self.factory.makeProduct() |
719 | + [ref1] = self.factory.makeGitRefs(target=project, owner=person) |
720 | + [ref2] = self.factory.makeGitRefs(target=project, owner=person) |
721 | + [ref3] = self.factory.makeGitRefs(target=project) |
722 | + self.factory.makeGitRefs(target=project) |
723 | + [target] = self.factory.makeGitRefs(target=project) |
724 | + mp1 = self.factory.makeBranchMergeProposalForGit( |
725 | + target_ref=target, source_ref=ref1) |
726 | + mp2 = self.factory.makeBranchMergeProposalForGit( |
727 | + target_ref=target, source_ref=ref2) |
728 | + self.factory.makeBranchMergeProposalForGit( |
729 | + target_ref=target, source_ref=ref3) |
730 | + collection = self.all_repositories.ownedBy(person) |
731 | + proposals = collection.getMergeProposals() |
732 | + self.assertEqual(sorted([mp1, mp2]), sorted(proposals)) |
733 | + |
734 | + def test_preloading_for_previewdiff(self): |
735 | + project = self.factory.makeProduct() |
736 | + [target] = self.factory.makeGitRefs(target=project) |
737 | + owner = self.factory.makePerson() |
738 | + [ref1] = self.factory.makeGitRefs(target=project, owner=owner) |
739 | + [ref2] = self.factory.makeGitRefs(target=project, owner=owner) |
740 | + bmp1 = self.factory.makeBranchMergeProposalForGit( |
741 | + target_ref=target, source_ref=ref1) |
742 | + bmp2 = self.factory.makeBranchMergeProposalForGit( |
743 | + target_ref=target, source_ref=ref2) |
744 | + old_date = datetime.now(pytz.UTC) - timedelta(hours=1) |
745 | + self.factory.makePreviewDiff( |
746 | + merge_proposal=bmp1, date_created=old_date) |
747 | + previewdiff1 = self.factory.makePreviewDiff(merge_proposal=bmp1) |
748 | + self.factory.makePreviewDiff( |
749 | + merge_proposal=bmp2, date_created=old_date) |
750 | + previewdiff2 = self.factory.makePreviewDiff(merge_proposal=bmp2) |
751 | + Store.of(bmp1).flush() |
752 | + Store.of(bmp1).invalidate() |
753 | + collection = self.all_repositories.ownedBy(owner) |
754 | + [pre_bmp1, pre_bmp2] = sorted( |
755 | + collection.getMergeProposals(eager_load=True), |
756 | + key=attrgetter('id')) |
757 | + with StormStatementRecorder() as recorder: |
758 | + self.assertEqual( |
759 | + removeSecurityProxy(pre_bmp1.preview_diff).id, previewdiff1.id) |
760 | + self.assertEqual( |
761 | + removeSecurityProxy(pre_bmp2.preview_diff).id, previewdiff2.id) |
762 | + self.assertThat(recorder, HasQueryCount(Equals(0))) |
763 | + |
764 | + def test_merge_proposals_in_project(self): |
765 | + mp1 = self.factory.makeBranchMergeProposalForGit() |
766 | + self.factory.makeBranchMergeProposalForGit() |
767 | + project = mp1.source_git_ref.target |
768 | + collection = self.all_repositories.inProject(project) |
769 | + proposals = collection.getMergeProposals() |
770 | + self.assertEqual([mp1], list(proposals)) |
771 | + |
772 | + def test_target_branch_private(self): |
773 | + # The target branch must be in the branch collection, as must the |
774 | + # source branch. |
775 | + registrant = self.factory.makePerson() |
776 | + mp1 = self.factory.makeBranchMergeProposalForGit(registrant=registrant) |
777 | + naked_repository = removeSecurityProxy(mp1.target_git_repository) |
778 | + naked_repository.transitionToInformationType( |
779 | + InformationType.USERDATA, registrant, verify_policy=False) |
780 | + collection = self.all_repositories.visibleByUser(None) |
781 | + proposals = collection.getMergeProposals() |
782 | + self.assertEqual([], list(proposals)) |
783 | + |
784 | + def test_status_restriction(self): |
785 | + mp1 = self.factory.makeBranchMergeProposalForGit( |
786 | + set_state=BranchMergeProposalStatus.WORK_IN_PROGRESS) |
787 | + mp2 = self.factory.makeBranchMergeProposalForGit( |
788 | + set_state=BranchMergeProposalStatus.NEEDS_REVIEW) |
789 | + self.factory.makeBranchMergeProposalForGit( |
790 | + set_state=BranchMergeProposalStatus.CODE_APPROVED) |
791 | + proposals = self.all_repositories.getMergeProposals( |
792 | + [BranchMergeProposalStatus.WORK_IN_PROGRESS, |
793 | + BranchMergeProposalStatus.NEEDS_REVIEW]) |
794 | + self.assertEqual(sorted([mp1, mp2]), sorted(proposals)) |
795 | + |
796 | + def test_status_restriction_with_project_filter(self): |
797 | + # getMergeProposals returns the merge proposals with a particular |
798 | + # status that are _inside_ the repository collection. mp1 is in the |
799 | + # product with NEEDS_REVIEW, mp2 is outside of the project and mp3 |
800 | + # has an excluded status. |
801 | + mp1 = self.factory.makeBranchMergeProposalForGit( |
802 | + set_state=BranchMergeProposalStatus.NEEDS_REVIEW) |
803 | + self.factory.makeBranchMergeProposalForGit( |
804 | + set_state=BranchMergeProposalStatus.NEEDS_REVIEW) |
805 | + project = mp1.source_git_ref.target |
806 | + [ref1] = self.factory.makeGitRefs(target=project) |
807 | + [ref2] = self.factory.makeGitRefs(target=project) |
808 | + self.factory.makeBranchMergeProposalForGit( |
809 | + target_ref=ref1, source_ref=ref2, |
810 | + set_state=BranchMergeProposalStatus.CODE_APPROVED) |
811 | + collection = self.all_repositories.inProject(project) |
812 | + proposals = collection.getMergeProposals( |
813 | + [BranchMergeProposalStatus.NEEDS_REVIEW]) |
814 | + self.assertEqual([mp1], list(proposals)) |
815 | + |
816 | + def test_specifying_target_repository(self): |
817 | + # If the target_repository is specified but not the target_path, |
818 | + # only merge proposals where that repository is the target are |
819 | + # returned. |
820 | + [ref1, ref2] = self.factory.makeGitRefs( |
821 | + paths=[u"refs/heads/ref1", u"refs/heads/ref2"]) |
822 | + mp1 = self.factory.makeBranchMergeProposalForGit(target_ref=ref1) |
823 | + mp2 = self.factory.makeBranchMergeProposalForGit(target_ref=ref2) |
824 | + self.factory.makeBranchMergeProposalForGit() |
825 | + proposals = self.all_repositories.getMergeProposals( |
826 | + target_repository=mp1.target_git_repository) |
827 | + self.assertEqual(sorted([mp1, mp2]), sorted(proposals)) |
828 | + |
829 | + def test_specifying_target_ref(self): |
830 | + # If the target_repository and target_path are specified, only merge |
831 | + # proposals where that ref is the target are returned. |
832 | + mp1 = self.factory.makeBranchMergeProposalForGit() |
833 | + self.factory.makeBranchMergeProposalForGit() |
834 | + proposals = self.all_repositories.getMergeProposals( |
835 | + target_repository=mp1.target_git_repository, |
836 | + target_path=mp1.target_git_path) |
837 | + self.assertEqual([mp1], list(proposals)) |
838 | + |
839 | + |
840 | +class TestBranchMergeProposalsForReviewer(TestCaseWithFactory): |
841 | + """Tests for IGitCollection.getProposalsForReviewer().""" |
842 | + |
843 | + layer = DatabaseFunctionalLayer |
844 | + |
845 | + def setUp(self): |
846 | + # Use the admin user as we don't care about who can and can't call |
847 | + # nominate reviewer in this test. |
848 | + TestCaseWithFactory.setUp(self, 'admin@canonical.com') |
849 | + self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"})) |
850 | + self.all_repositories = getUtility(IAllGitRepositories) |
851 | + |
852 | + def test_getProposalsForReviewer(self): |
853 | + reviewer = self.factory.makePerson() |
854 | + proposal = self.factory.makeBranchMergeProposalForGit() |
855 | + proposal.nominateReviewer(reviewer, reviewer) |
856 | + self.factory.makeBranchMergeProposalForGit() |
857 | + proposals = self.all_repositories.getMergeProposalsForReviewer( |
858 | + reviewer) |
859 | + self.assertEqual([proposal], list(proposals)) |
860 | + |
861 | + def test_getProposalsForReviewer_filter_status(self): |
862 | + reviewer = self.factory.makePerson() |
863 | + proposal1 = self.factory.makeBranchMergeProposalForGit( |
864 | + set_state=BranchMergeProposalStatus.NEEDS_REVIEW) |
865 | + proposal1.nominateReviewer(reviewer, reviewer) |
866 | + proposal2 = self.factory.makeBranchMergeProposalForGit( |
867 | + set_state=BranchMergeProposalStatus.WORK_IN_PROGRESS) |
868 | + proposal2.nominateReviewer(reviewer, reviewer) |
869 | + proposals = self.all_repositories.getMergeProposalsForReviewer( |
870 | + reviewer, [BranchMergeProposalStatus.NEEDS_REVIEW]) |
871 | + self.assertEqual([proposal1], list(proposals)) |
872 | + |
873 | + def test_getProposalsForReviewer_anonymous(self): |
874 | + # Don't include proposals if the target branch is private for |
875 | + # anonymous views. |
876 | + reviewer = self.factory.makePerson() |
877 | + [target_ref] = self.factory.makeGitRefs( |
878 | + information_type=InformationType.USERDATA) |
879 | + proposal = self.factory.makeBranchMergeProposalForGit( |
880 | + target_ref=target_ref) |
881 | + proposal.nominateReviewer(reviewer, reviewer) |
882 | + proposals = self.all_repositories.visibleByUser( |
883 | + None).getMergeProposalsForReviewer(reviewer) |
884 | + self.assertEqual([], list(proposals)) |
885 | + |
886 | + def test_getProposalsForReviewer_anonymous_source_private(self): |
887 | + # Don't include proposals if the source branch is private for |
888 | + # anonymous views. |
889 | + reviewer = self.factory.makePerson() |
890 | + project = self.factory.makeProduct() |
891 | + [source_ref] = self.factory.makeGitRefs( |
892 | + target=project, information_type=InformationType.USERDATA) |
893 | + [target_ref] = self.factory.makeGitRefs(target=project) |
894 | + proposal = self.factory.makeBranchMergeProposalForGit( |
895 | + source_ref=source_ref, target_ref=target_ref) |
896 | + proposal.nominateReviewer(reviewer, reviewer) |
897 | + proposals = self.all_repositories.visibleByUser( |
898 | + None).getMergeProposalsForReviewer(reviewer) |
899 | + self.assertEqual([], list(proposals)) |
900 | + |
901 | + def test_getProposalsForReviewer_for_product(self): |
902 | + reviewer = self.factory.makePerson() |
903 | + proposal = self.factory.makeBranchMergeProposalForGit() |
904 | + proposal.nominateReviewer(reviewer, reviewer) |
905 | + proposal2 = self.factory.makeBranchMergeProposalForGit() |
906 | + proposal2.nominateReviewer(reviewer, reviewer) |
907 | + proposals = self.all_repositories.inProject( |
908 | + proposal.merge_source.target).getMergeProposalsForReviewer( |
909 | + reviewer) |
910 | + self.assertEqual([proposal], list(proposals)) |
911 | + |
912 | |
913 | class TestGenericGitCollectionVisibleFilter(TestCaseWithFactory): |
914 |