Merge lp:~cjwatson/launchpad/git-mp-collection into lp:launchpad

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
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