Merge lp:~cjwatson/launchpad/git-mp-basic-browser into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 17463
Proposed branch: lp:~cjwatson/launchpad/git-mp-basic-browser
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/storm-vocabulary-base
Diff against target: 1026 lines (+562/-85)
13 files modified
lib/lp/code/browser/branchmergeproposal.py (+95/-27)
lib/lp/code/browser/configure.zcml (+3/-0)
lib/lp/code/browser/gitref.py (+61/-0)
lib/lp/code/browser/tests/test_branchmergeproposal.py (+208/-54)
lib/lp/code/errors.py (+9/-2)
lib/lp/code/interfaces/gitrepository.py (+34/-0)
lib/lp/code/model/gitref.py (+3/-0)
lib/lp/code/model/gitrepository.py (+22/-0)
lib/lp/code/model/tests/test_gitrepository.py (+74/-0)
lib/lp/code/templates/branchmergeproposal-resubmit.pt (+1/-1)
lib/lp/code/templates/gitref-index.pt (+7/-0)
lib/lp/code/templates/gitref-pending-merges.pt (+43/-0)
lib/lp/testing/factory.py (+2/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-mp-basic-browser
Reviewer Review Type Date Requested Status
William Grant (community) code Approve
Review via email: mp+257674@code.launchpad.net

This proposal supersedes a proposal from 2015-04-28.

Commit message

Add Git support to most of the merge proposal views.

Description of the change

Add Git support to most of the merge proposal views.

GitRef:+register-merge is about the same size again, and will be in a separate MP.

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

One bit of useless code, otherwise looks good.

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/browser/branchmergeproposal.py'
2--- lib/lp/code/browser/branchmergeproposal.py 2015-04-22 16:41:41 +0000
3+++ lib/lp/code/browser/branchmergeproposal.py 2015-04-29 12:39:54 +0000
4@@ -86,6 +86,7 @@
5 InvalidBranchMergeProposal,
6 WrongBranchMergeProposal,
7 )
8+from lp.code.interfaces.branch import IBranch
9 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
10 from lp.code.interfaces.codereviewcomment import ICodeReviewComment
11 from lp.code.interfaces.codereviewinlinecomment import (
12@@ -355,7 +356,7 @@
13 @property
14 def codebrowse_url(self):
15 """Return the link to codebrowse for this branch."""
16- return self.context.source_branch.getCodebrowseUrl()
17+ return self.context.merge_source.getCodebrowseUrl()
18
19
20 class BranchMergeProposalRevisionIdMixin:
21@@ -373,6 +374,9 @@
22 return None
23 # If the source branch is REMOTE, then there won't be any ids.
24 source_branch = self.context.source_branch
25+ if source_branch is None:
26+ # Git doesn't have revision numbers. Just use the ids.
27+ return revision_id
28 if source_branch.branch_type == BranchType.REMOTE:
29 return revision_id
30 else:
31@@ -586,12 +590,18 @@
32 def initialize(self):
33 super(BranchMergeProposalView, self).initialize()
34 cache = IJSONRequestCache(self.request)
35- cache.objects.update({
36- 'branch_diff_link':
37- 'https://%s/+loggerhead/%s/diff/' % (
38- config.launchpad.code_domain,
39- self.context.source_branch.unique_name),
40- })
41+ if IBranch.providedBy(self.context.merge_source):
42+ cache.objects.update({
43+ 'branch_diff_link':
44+ 'https://%s/+loggerhead/%s/diff/' % (
45+ config.launchpad.code_domain,
46+ self.context.source_branch.unique_name),
47+ })
48+ else:
49+ # XXX cjwatson 2015-04-29: Unimplemented for Git; this would
50+ # require something in the webapp which proxies diff requests to
51+ # the code browser, or an integrated code browser.
52+ pass
53 if getFeatureFlag("longpoll.merge_proposals.enabled"):
54 cache.objects['merge_proposal_event_key'] = subscribe(
55 self.context).event_key
56@@ -618,7 +628,9 @@
57 # Sort the comments by date order.
58 merge_proposal = self.context
59 groups = list(merge_proposal.getRevisionsSinceReviewStart())
60- source = DecoratedBranch(merge_proposal.source_branch)
61+ source = merge_proposal.merge_source
62+ if IBranch.providedBy(source):
63+ source = DecoratedBranch(source)
64 comments = []
65 if getFeatureFlag('code.incremental_diffs.enabled'):
66 ranges = [
67@@ -685,6 +697,8 @@
68
69 @property
70 def pending_diff(self):
71+ if self.context.source_branch is None:
72+ return False
73 return (
74 self.context.next_preview_diff_job is not None or
75 self.context.source_branch.pending_writes)
76@@ -705,6 +719,10 @@
77
78 @property
79 def spec_links(self):
80+ if self.context.source_branch is None:
81+ # XXX cjwatson 2015-04-16: Implement once Git refs have linked
82+ # blueprints.
83+ return []
84 return list(
85 self.context.source_branch.getSpecificationLinks(self.user))
86
87@@ -748,12 +766,16 @@
88 @property
89 def status_config(self):
90 """The config to configure the ChoiceSource JS widget."""
91+ if IBranch.providedBy(self.context.merge_source):
92+ source_revid = self.context.merge_source.last_scanned_id
93+ else:
94+ source_revid = self.context.merge_source.commit_sha1
95 return simplejson.dumps({
96 'status_widget_items': vocabulary_to_choice_edit_items(
97 self._createStatusVocabulary(),
98 css_class_prefix='mergestatus'),
99 'status_value': self.context.queue_status.title,
100- 'source_revid': self.context.source_branch.last_scanned_id,
101+ 'source_revid': source_revid,
102 'user_can_edit_status': check_permission(
103 'launchpad.Edit', self.context),
104 })
105@@ -965,19 +987,35 @@
106 schema = ResubmitSchema
107 for_input = True
108 page_title = label = "Resubmit proposal to merge"
109- field_names = [
110- 'source_branch',
111- 'target_branch',
112- 'prerequisite_branch',
113- 'description',
114- 'break_link',
115- ]
116
117 def initialize(self):
118 self.cancel_url = canonical_url(self.context)
119 super(BranchMergeProposalResubmitView, self).initialize()
120
121 @property
122+ def field_names(self):
123+ if IBranch.providedBy(self.context.merge_source):
124+ field_names = [
125+ 'source_branch',
126+ 'target_branch',
127+ 'prerequisite_branch',
128+ ]
129+ else:
130+ field_names = [
131+ 'source_git_repository',
132+ 'source_git_path',
133+ 'target_git_repository',
134+ 'target_git_path',
135+ 'prerequisite_git_repository',
136+ 'prerequisite_git_path',
137+ ]
138+ field_names.extend([
139+ 'description',
140+ 'break_link',
141+ ])
142+ return field_names
143+
144+ @property
145 def initial_values(self):
146 UNSET = object()
147 items = ((key, getattr(self.context, key, UNSET)) for key in
148@@ -988,10 +1026,24 @@
149 def resubmit_action(self, action, data):
150 """Resubmit this proposal."""
151 try:
152+ if IBranch.providedBy(self.context.merge_source):
153+ merge_source = data['source_branch']
154+ merge_target = data['target_branch']
155+ merge_prerequisite = data['prerequisite_branch']
156+ else:
157+ merge_source = data['source_git_repository'].getRefByPath(
158+ data['source_git_path'])
159+ merge_target = data['target_git_repository'].getRefByPath(
160+ data['target_git_path'])
161+ if data['prerequisite_git_repository']:
162+ merge_prerequisite = (
163+ data['prerequisite_git_repository'].getRefByPath(
164+ data['prerequisite_git_path']))
165+ else:
166+ merge_prerequisite = None
167 proposal = self.context.resubmit(
168- self.user, data['source_branch'], data['target_branch'],
169- data['prerequisite_branch'], data['description'],
170- data['break_link'])
171+ self.user, merge_source, merge_target, merge_prerequisite,
172+ data['description'], data['break_link'])
173 except BranchMergeProposalExists as e:
174 message = structured(
175 'Cannot resubmit because <a href="%(url)s">a similar merge'
176@@ -1072,18 +1124,31 @@
177 """The view to mark a merge proposal as merged."""
178 schema = IBranchMergeProposal
179 page_title = label = "Edit branch merge proposal"
180- field_names = ["merged_revno"]
181 for_input = True
182
183 @property
184+ def field_names(self):
185+ if IBranch.providedBy(self.context.merge_target):
186+ return ["merged_revno"]
187+ else:
188+ return ["merged_revision_id"]
189+
190+ @property
191 def initial_values(self):
192 # Default to the tip of the target branch, on the assumption that the
193 # source branch has just been merged into it.
194- if self.context.merged_revno is not None:
195- revno = self.context.merged_revno
196+ if IBranch.providedBy(self.context.merge_target):
197+ if self.context.merged_revno is not None:
198+ revno = self.context.merged_revno
199+ else:
200+ revno = self.context.merge_target.revision_count
201+ return {'merged_revno': revno}
202 else:
203- revno = self.context.target_branch.revision_count
204- return {'merged_revno': revno}
205+ if self.context.merged_revision_id is not None:
206+ revision_id = self.context.merged_revision_id
207+ else:
208+ revision_id = self.context.merge_target.commit_sha1
209+ return {'merged_revision_id': revision_id}
210
211 @property
212 def next_url(self):
213@@ -1095,13 +1160,16 @@
214 @notify
215 def mark_merged_action(self, action, data):
216 """Update the whiteboard and go back to the source branch."""
217- revno = data['merged_revno']
218+ if IBranch.providedBy(self.context.merge_target):
219+ kwargs = {'merged_revno': data['merged_revno']}
220+ else:
221+ kwargs = {'merged_revision_id': data['merged_revision_id']}
222 if self.context.queue_status == BranchMergeProposalStatus.MERGED:
223- self.context.markAsMerged(merged_revno=revno)
224+ self.context.markAsMerged(**kwargs)
225 self.request.response.addNotification(
226 'The proposal\'s merged revision has been updated.')
227 else:
228- self.context.markAsMerged(revno, merge_reporter=self.user)
229+ self.context.markAsMerged(merge_reporter=self.user, **kwargs)
230 self.request.response.addNotification(
231 'The proposal has now been marked as merged.')
232
233
234=== modified file 'lib/lp/code/browser/configure.zcml'
235--- lib/lp/code/browser/configure.zcml 2015-04-22 16:11:40 +0000
236+++ lib/lp/code/browser/configure.zcml 2015-04-29 12:39:54 +0000
237@@ -824,6 +824,9 @@
238 <browser:page
239 name="++ref-commits"
240 template="../templates/gitref-commits.pt"/>
241+ <browser:page
242+ name="++ref-pending-merges"
243+ template="../templates/gitref-pending-merges.pt"/>
244 </browser:pages>
245 <browser:page
246 for="lp.code.interfaces.gitref.IGitRef"
247
248=== modified file 'lib/lp/code/browser/gitref.py'
249--- lib/lp/code/browser/gitref.py 2015-04-22 16:11:40 +0000
250+++ lib/lp/code/browser/gitref.py 2015-04-29 12:39:54 +0000
251@@ -10,12 +10,19 @@
252 'GitRefView',
253 ]
254
255+from lp.code.browser.branchmergeproposal import (
256+ latest_proposals_for_each_branch,
257+ )
258 from lp.code.interfaces.gitref import IGitRef
259+from lp.code.model.gitrepository import GitRepository
260+from lp.services.database.bulk import load_related
261+from lp.services.propertycache import cachedproperty
262 from lp.services.webapp import (
263 LaunchpadView,
264 Navigation,
265 stepthrough,
266 )
267+from lp.services.webapp.authorization import check_permission
268
269
270 class GitRefNavigation(Navigation):
271@@ -49,3 +56,57 @@
272 "author_date": self.context.author_date,
273 "commit_message": self.context.commit_message,
274 }
275+
276+ @property
277+ def show_merge_links(self):
278+ """Return whether or not merge proposal links should be shown.
279+
280+ Merge proposal links should not be shown if there is only one
281+ reference in the entire target.
282+ """
283+ if not self.context.namespace.supports_merge_proposals:
284+ return False
285+ repositories = self.context.namespace.collection.getRepositories()
286+ if repositories.count() > 1:
287+ return True
288+ repository = repositories.one()
289+ if repository is None:
290+ return False
291+ return repository.refs.count() > 1
292+
293+ @cachedproperty
294+ def landing_targets(self):
295+ """Return a filtered list of landing targets."""
296+ return latest_proposals_for_each_branch(self.context.landing_targets)
297+
298+ @cachedproperty
299+ def landing_candidates(self):
300+ """Return a decorated list of landing candidates."""
301+ candidates = list(self.context.landing_candidates)
302+ load_related(
303+ GitRepository, candidates,
304+ ["source_git_repositoryID", "prerequisite_git_repositoryID"])
305+ return [proposal for proposal in candidates
306+ if check_permission("launchpad.View", proposal)]
307+
308+ def _getBranchCountText(self, count):
309+ """Help to show user friendly text."""
310+ if count == 0:
311+ return 'No branches'
312+ elif count == 1:
313+ return '1 branch'
314+ else:
315+ return '%s branches' % count
316+
317+ @cachedproperty
318+ def landing_candidate_count_text(self):
319+ return self._getBranchCountText(len(self.landing_candidates))
320+
321+ @cachedproperty
322+ def dependent_landings(self):
323+ return [proposal for proposal in self.context.dependent_landings
324+ if check_permission("launchpad.View", proposal)]
325+
326+ @cachedproperty
327+ def dependent_landing_count_text(self):
328+ return self._getBranchCountText(len(self.dependent_landings))
329
330=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
331--- lib/lp/code/browser/tests/test_branchmergeproposal.py 2015-04-19 12:56:32 +0000
332+++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2015-04-29 12:39:54 +0000
333@@ -46,6 +46,7 @@
334 BranchMergeProposalStatus,
335 CodeReviewVote,
336 )
337+from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
338 from lp.code.model.diff import PreviewDiff
339 from lp.code.tests.helpers import (
340 add_revision_to_branch,
341@@ -55,6 +56,7 @@
342 PersonVisibility,
343 TeamMembershipPolicy,
344 )
345+from lp.services.features.testing import FeatureFixture
346 from lp.services.librarian.interfaces.client import LibrarianServerError
347 from lp.services.messages.model.message import MessageSet
348 from lp.services.webapp import canonical_url
349@@ -107,8 +109,8 @@
350 self.assertTrue(d.can_change_review)
351
352
353-class TestBranchMergeProposalMergedView(TestCaseWithFactory):
354- """Tests for `BranchMergeProposalMergedView`."""
355+class TestBranchMergeProposalMergedViewBzr(TestCaseWithFactory):
356+ """Tests for `BranchMergeProposalMergedView` for Bazaar."""
357
358 layer = DatabaseFunctionalLayer
359
360@@ -129,6 +131,30 @@
361 view.initial_values)
362
363
364+class TestBranchMergeProposalMergedViewGit(TestCaseWithFactory):
365+ """Tests for `BranchMergeProposalMergedView` for Git."""
366+
367+ layer = DatabaseFunctionalLayer
368+
369+ def setUp(self):
370+ # Use an admin so we don't have to worry about launchpad.Edit
371+ # permissions on the merge proposals for adding comments, or
372+ # nominating reviewers.
373+ TestCaseWithFactory.setUp(self, user="admin@canonical.com")
374+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
375+ self.bmp = self.factory.makeBranchMergeProposalForGit()
376+
377+ def test_initial_values(self):
378+ # The default merged_revision_id is the head commit SHA-1 of the
379+ # target ref.
380+ view = BranchMergeProposalMergedView(self.bmp, LaunchpadTestRequest())
381+ removeSecurityProxy(self.bmp.source_git_ref).commit_sha1 = "0" * 40
382+ removeSecurityProxy(self.bmp.target_git_ref).commit_sha1 = "1" * 40
383+ self.assertEqual(
384+ {'merged_revision_id': self.bmp.target_git_ref.commit_sha1},
385+ view.initial_values)
386+
387+
388 class TestBranchMergeProposalAddVoteView(TestCaseWithFactory):
389 """Test the AddVote view."""
390
391@@ -661,14 +687,14 @@
392 self.assertEqual(BrowserNotificationLevel.INFO, notification.level)
393
394
395-class TestBranchMergeProposalResubmitView(TestCaseWithFactory):
396+class TestBranchMergeProposalResubmitViewMixin:
397 """Test BranchMergeProposalResubmitView."""
398
399 layer = DatabaseFunctionalLayer
400
401 def createView(self):
402 """Create the required view."""
403- context = self.factory.makeBranchMergeProposal()
404+ context = self._makeBranchMergeProposal()
405 self.useContext(person_logged_in(context.registrant))
406 view = BranchMergeProposalResubmitView(
407 context, LaunchpadTestRequest())
408@@ -679,36 +705,34 @@
409 """resubmit_action resubmits the proposal."""
410 view = self.createView()
411 context = view.context
412- new_proposal = view.resubmit_action.success(
413- {'source_branch': context.source_branch,
414- 'target_branch': context.target_branch,
415- 'prerequisite_branch': context.prerequisite_branch,
416- 'description': None,
417- 'break_link': False,
418- })
419+ new_proposal = view.resubmit_action.success(self._getFormValues(
420+ context.merge_source, context.merge_target,
421+ context.merge_prerequisite, {
422+ 'description': None,
423+ 'break_link': False,
424+ }))
425 self.assertEqual(new_proposal.supersedes, context)
426- self.assertEqual(new_proposal.source_branch, context.source_branch)
427- self.assertEqual(new_proposal.target_branch, context.target_branch)
428+ self.assertEqual(new_proposal.merge_source, context.merge_source)
429+ self.assertEqual(new_proposal.merge_target, context.merge_target)
430 self.assertEqual(
431- new_proposal.prerequisite_branch, context.prerequisite_branch)
432+ new_proposal.merge_prerequisite, context.merge_prerequisite)
433
434 def test_resubmit_action_change_branches(self):
435 """Changing the branches changes the branches in the new proposal."""
436 view = self.createView()
437- target = view.context.source_branch.target
438- new_source = self.factory.makeBranchTargetBranch(target)
439- new_target = self.factory.makeBranchTargetBranch(target)
440- new_prerequisite = self.factory.makeBranchTargetBranch(target)
441- new_proposal = view.resubmit_action.success(
442- {'source_branch': new_source, 'target_branch': new_target,
443- 'prerequisite_branch': new_prerequisite,
444- 'description': 'description',
445- 'break_link': False,
446- })
447+ new_source = self._makeBranchWithSameTarget(view.context.merge_source)
448+ new_target = self._makeBranchWithSameTarget(view.context.merge_source)
449+ new_prerequisite = self._makeBranchWithSameTarget(
450+ view.context.merge_source)
451+ new_proposal = view.resubmit_action.success(self._getFormValues(
452+ new_source, new_target, new_prerequisite, {
453+ 'description': 'description',
454+ 'break_link': False,
455+ }))
456 self.assertEqual(new_proposal.supersedes, view.context)
457- self.assertEqual(new_proposal.source_branch, new_source)
458- self.assertEqual(new_proposal.target_branch, new_target)
459- self.assertEqual(new_proposal.prerequisite_branch, new_prerequisite)
460+ self.assertEqual(new_proposal.merge_source, new_source)
461+ self.assertEqual(new_proposal.merge_target, new_target)
462+ self.assertEqual(new_proposal.merge_prerequisite, new_prerequisite)
463
464 def test_resubmit_action_break_link(self):
465 """Enabling break_link prevents linking the old and new proposals."""
466@@ -716,24 +740,22 @@
467 new_proposal = self.resubmitDefault(view, break_link=True)
468 self.assertIs(None, new_proposal.supersedes)
469
470- @staticmethod
471- def resubmitDefault(view, break_link=False, prerequisite_branch=None):
472+ @classmethod
473+ def resubmitDefault(cls, view, break_link=False, merge_prerequisite=None):
474 context = view.context
475- if prerequisite_branch is None:
476- prerequisite_branch = context.prerequisite_branch
477- return view.resubmit_action.success(
478- {'source_branch': context.source_branch,
479- 'target_branch': context.target_branch,
480- 'prerequisite_branch': prerequisite_branch,
481- 'description': None,
482- 'break_link': break_link,
483- })
484+ if merge_prerequisite is None:
485+ merge_prerequisite = context.merge_prerequisite
486+ return view.resubmit_action.success(cls._getFormValues(
487+ context.merge_source, context.merge_target, merge_prerequisite, {
488+ 'description': None,
489+ 'break_link': break_link,
490+ }))
491
492 def test_resubmit_existing(self):
493 """Resubmitting a proposal when another is active is a user error."""
494 view = self.createView()
495 first_bmp = view.context
496- with person_logged_in(first_bmp.target_branch.owner):
497+ with person_logged_in(first_bmp.merge_target.owner):
498 first_bmp.resubmit(first_bmp.registrant)
499 self.resubmitDefault(view)
500 (notification,) = view.request.response.notifications
501@@ -742,19 +764,86 @@
502 ' <a href=.*>a similar merge proposal</a> is already active.'))
503 self.assertEqual(BrowserNotificationLevel.ERROR, notification.level)
504
505+
506+class TestBranchMergeProposalResubmitViewBzr(
507+ TestBranchMergeProposalResubmitViewMixin, TestCaseWithFactory):
508+ """Test BranchMergeProposalResubmitView for Bazaar."""
509+
510+ def _makeBranchMergeProposal(self):
511+ return self.factory.makeBranchMergeProposal()
512+
513+ @staticmethod
514+ def _getFormValues(source_branch, target_branch, prerequisite_branch,
515+ extras):
516+ values = {
517+ 'source_branch': source_branch,
518+ 'target_branch': target_branch,
519+ 'prerequisite_branch': prerequisite_branch,
520+ }
521+ values.update(extras)
522+ return values
523+
524+ def _makeBranchWithSameTarget(self, branch):
525+ return self.factory.makeBranchTargetBranch(branch.target)
526+
527 def test_resubmit_same_target_prerequisite(self):
528 """User error if same branch is target and prerequisite."""
529 view = self.createView()
530 first_bmp = view.context
531- self.resubmitDefault(
532- view, prerequisite_branch=first_bmp.target_branch)
533+ self.resubmitDefault(view, merge_prerequisite=first_bmp.merge_target)
534 self.assertEqual(
535 view.errors,
536 ['Target and prerequisite branches must be different.'])
537
538
539-class TestResubmitBrowser(BrowserTestCase):
540- """Browser tests for resubmitting branch merge proposals."""
541+class TestBranchMergeProposalResubmitViewGit(
542+ TestBranchMergeProposalResubmitViewMixin, TestCaseWithFactory):
543+ """Test BranchMergeProposalResubmitView for Git."""
544+
545+ def setUp(self):
546+ super(TestBranchMergeProposalResubmitViewGit, self).setUp()
547+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
548+
549+ def _makeBranchMergeProposal(self):
550+ return self.factory.makeBranchMergeProposalForGit()
551+
552+ @staticmethod
553+ def _getFormValues(source_branch, target_branch, prerequisite_branch,
554+ extras):
555+ values = {
556+ 'source_git_repository': source_branch.repository,
557+ 'source_git_path': source_branch.path,
558+ 'target_git_repository': target_branch.repository,
559+ 'target_git_path': target_branch.path,
560+ }
561+ if prerequisite_branch is not None:
562+ values.update({
563+ 'prerequisite_git_repository': prerequisite_branch.repository,
564+ 'prerequisite_git_path': prerequisite_branch.path,
565+ })
566+ else:
567+ values.update({
568+ 'prerequisite_git_repository': None,
569+ 'prerequisite_git_path': '',
570+ })
571+ values.update(extras)
572+ return values
573+
574+ def _makeBranchWithSameTarget(self, branch):
575+ return self.factory.makeGitRefs(target=branch.target)[0]
576+
577+ def test_resubmit_same_target_prerequisite(self):
578+ """User error if same branch is target and prerequisite."""
579+ view = self.createView()
580+ first_bmp = view.context
581+ self.resubmitDefault(view, merge_prerequisite=first_bmp.merge_target)
582+ self.assertEqual(
583+ view.errors,
584+ ['Target and prerequisite references must be different.'])
585+
586+
587+class TestResubmitBrowserBzr(BrowserTestCase):
588+ """Browser tests for resubmitting branch merge proposals for Bazaar."""
589
590 layer = DatabaseFunctionalLayer
591
592@@ -781,6 +870,41 @@
593 self.assertEqual('flibble', bmp.superseded_by.description)
594
595
596+class TestResubmitBrowserGit(BrowserTestCase):
597+ """Browser tests for resubmitting branch merge proposals for Git."""
598+
599+ layer = DatabaseFunctionalLayer
600+
601+ def setUp(self):
602+ super(TestResubmitBrowserGit, self).setUp()
603+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
604+
605+ def test_resubmit_text(self):
606+ """The text of the resubmit page is as expected."""
607+ bmp = self.factory.makeBranchMergeProposalForGit(registrant=self.user)
608+ text = self.getMainText(bmp, '+resubmit')
609+ expected = (
610+ 'Resubmit proposal to merge.*'
611+ 'Source Git Repository:.*'
612+ 'Source Git branch path:.*'
613+ 'Target Git Repository:.*'
614+ 'Target Git branch path:.*'
615+ 'Prerequisite Git Repository:.*'
616+ 'Prerequisite Git branch path:.*'
617+ 'Description.*'
618+ 'Start afresh.*')
619+ self.assertTextMatchesExpressionIgnoreWhitespace(expected, text)
620+
621+ def test_resubmit_controls(self):
622+ """Proposals can be resubmitted using the browser."""
623+ bmp = self.factory.makeBranchMergeProposalForGit(registrant=self.user)
624+ browser = self.getViewBrowser(bmp, '+resubmit')
625+ browser.getControl('Description').value = 'flibble'
626+ browser.getControl('Resubmit').click()
627+ with person_logged_in(self.user):
628+ self.assertEqual('flibble', bmp.superseded_by.description)
629+
630+
631 class TestBranchMergeProposalView(TestCaseWithFactory):
632
633 layer = LaunchpadFunctionalLayer
634@@ -1248,17 +1372,17 @@
635 self.assertEqual(mp_url, browser.url)
636
637
638-class TestLatestProposalsForEachBranch(TestCaseWithFactory):
639+class TestLatestProposalsForEachBranchMixin:
640 """Confirm that the latest branch is returned."""
641
642 layer = DatabaseFunctionalLayer
643
644 def test_newest_first(self):
645 # If each proposal targets a different branch, each will be returned.
646- bmp1 = self.factory.makeBranchMergeProposal(
647+ bmp1 = self._makeBranchMergeProposal(
648 date_created=(
649 datetime(year=2008, month=9, day=10, tzinfo=pytz.UTC)))
650- bmp2 = self.factory.makeBranchMergeProposal(
651+ bmp2 = self._makeBranchMergeProposal(
652 date_created=(
653 datetime(year=2008, month=10, day=10, tzinfo=pytz.UTC)))
654 self.assertEqual(
655@@ -1266,27 +1390,57 @@
656
657 def test_visible_filtered_out(self):
658 # If the proposal is not visible to the user, they are not returned.
659- bmp1 = self.factory.makeBranchMergeProposal(
660+ bmp1 = self._makeBranchMergeProposal(
661 date_created=(
662 datetime(year=2008, month=9, day=10, tzinfo=pytz.UTC)))
663- bmp2 = self.factory.makeBranchMergeProposal(
664+ bmp2 = self._makeBranchMergeProposal(
665 date_created=(
666 datetime(year=2008, month=10, day=10, tzinfo=pytz.UTC)))
667- removeSecurityProxy(bmp2.source_branch).transitionToInformationType(
668- InformationType.USERDATA, bmp2.source_branch.owner,
669- verify_policy=False)
670+ self._setBranchInvisible(bmp2.merge_source)
671 self.assertEqual(
672 [bmp1], latest_proposals_for_each_branch([bmp1, bmp2]))
673
674 def test_same_target(self):
675 # If the proposals target the same branch, then the most recent is
676 # returned.
677- bmp1 = self.factory.makeBranchMergeProposal(
678+ bmp1 = self._makeBranchMergeProposal(
679 date_created=(
680 datetime(year=2008, month=9, day=10, tzinfo=pytz.UTC)))
681- bmp2 = self.factory.makeBranchMergeProposal(
682- target_branch=bmp1.target_branch,
683+ bmp2 = self._makeBranchMergeProposal(
684+ merge_target=bmp1.merge_target,
685 date_created=(
686 datetime(year=2008, month=10, day=10, tzinfo=pytz.UTC)))
687 self.assertEqual(
688 [bmp2], latest_proposals_for_each_branch([bmp1, bmp2]))
689+
690+
691+class TestLatestProposalsForEachBranchBzr(
692+ TestLatestProposalsForEachBranchMixin, TestCaseWithFactory):
693+ """Confirm that the latest branch is returned for Bazaar."""
694+
695+ def _makeBranchMergeProposal(self, merge_target=None, **kwargs):
696+ return self.factory.makeBranchMergeProposal(
697+ target_branch=merge_target, **kwargs)
698+
699+ @staticmethod
700+ def _setBranchInvisible(branch):
701+ removeSecurityProxy(branch).transitionToInformationType(
702+ InformationType.USERDATA, branch.owner, verify_policy=False)
703+
704+
705+class TestLatestProposalsForEachBranchGit(
706+ TestLatestProposalsForEachBranchMixin, TestCaseWithFactory):
707+ """Confirm that the latest branch is returned for Bazaar."""
708+
709+ def setUp(self):
710+ super(TestLatestProposalsForEachBranchGit, self).setUp()
711+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
712+
713+ def _makeBranchMergeProposal(self, merge_target=None, **kwargs):
714+ return self.factory.makeBranchMergeProposalForGit(
715+ target_ref=merge_target, **kwargs)
716+
717+ @staticmethod
718+ def _setBranchInvisible(branch):
719+ removeSecurityProxy(branch.repository).transitionToInformationType(
720+ InformationType.USERDATA, branch.owner, verify_policy=False)
721
722=== modified file 'lib/lp/code/errors.py'
723--- lib/lp/code/errors.py 2015-04-19 12:56:32 +0000
724+++ lib/lp/code/errors.py 2015-04-29 12:39:54 +0000
725@@ -231,11 +231,18 @@
726 """Raised if there is already a matching BranchMergeProposal."""
727
728 def __init__(self, existing_proposal):
729+ # Circular import.
730+ from lp.code.interfaces.branch import IBranch
731+ # display_name is the newer style, but IBranch uses the older style.
732+ if IBranch.providedBy(existing_proposal.merge_source):
733+ display_name = "displayname"
734+ else:
735+ display_name = "display_name"
736 super(BranchMergeProposalExists, self).__init__(
737 'There is already a branch merge proposal registered for '
738 'branch %s to land on %s that is still active.' %
739- (existing_proposal.source_branch.displayname,
740- existing_proposal.target_branch.displayname))
741+ (getattr(existing_proposal.merge_source, display_name),
742+ getattr(existing_proposal.merge_target, display_name)))
743 self.existing_proposal = existing_proposal
744
745
746
747=== modified file 'lib/lp/code/interfaces/gitrepository.py'
748--- lib/lp/code/interfaces/gitrepository.py 2015-04-22 16:11:40 +0000
749+++ lib/lp/code/interfaces/gitrepository.py 2015-04-29 12:39:54 +0000
750@@ -49,6 +49,7 @@
751 Choice,
752 Datetime,
753 Int,
754+ List,
755 Text,
756 TextLine,
757 )
758@@ -607,6 +608,39 @@
759 :return: A collection of `IGitRepository` objects.
760 """
761
762+ @call_with(user=REQUEST_USER)
763+ @operation_parameters(
764+ person=Reference(
765+ title=_("The person whose repository visibility is being "
766+ "checked."),
767+ schema=IPerson),
768+ repository_names=List(value_type=Text(),
769+ title=_('List of repository unique names'), required=True),
770+ )
771+ @export_read_operation()
772+ @operation_for_version("devel")
773+ def getRepositoryVisibilityInfo(user, person, repository_names):
774+ """Return the named repositories visible to both user and person.
775+
776+ Anonymous requesters don't get any information.
777+
778+ :param user: The user requesting the information. If the user is
779+ None then we return an empty dict.
780+ :param person: The person whose repository visibility we wish to
781+ check.
782+ :param repository_names: The unique names of the repositories to
783+ check.
784+
785+ Return a dict with the following values:
786+ person_name: the displayname of the person.
787+ visible_repositories: a list of the unique names of the repositories
788+ which the requester and specified person can both see.
789+
790+ This API call is provided for use by the client Javascript. It is
791+ not designed to efficiently scale to handle requests for large
792+ numbers of repositories.
793+ """
794+
795 @operation_parameters(
796 target=Reference(
797 title=_("Target"), required=True, schema=IHasGitRepositories))
798
799=== modified file 'lib/lp/code/model/gitref.py'
800--- lib/lp/code/model/gitref.py 2015-04-24 12:58:46 +0000
801+++ lib/lp/code/model/gitref.py 2015-04-29 12:39:54 +0000
802@@ -376,3 +376,6 @@
803
804 def __ne__(self, other):
805 return not self == other
806+
807+ def __hash__(self):
808+ return hash(self.repository) ^ hash(self.path) ^ hash(self.commit_sha1)
809
810=== modified file 'lib/lp/code/model/gitrepository.py'
811--- lib/lp/code/model/gitrepository.py 2015-04-22 16:42:57 +0000
812+++ lib/lp/code/model/gitrepository.py 2015-04-29 12:39:54 +0000
813@@ -38,6 +38,7 @@
814 from storm.store import Store
815 from zope.component import getUtility
816 from zope.interface import implements
817+from zope.security.interfaces import Unauthorized
818 from zope.security.proxy import removeSecurityProxy
819
820 from lp.app.enums import (
821@@ -774,6 +775,27 @@
822 collection = IGitCollection(target).visibleByUser(user)
823 return collection.getRepositories(eager_load=True)
824
825+ def getRepositoryVisibilityInfo(self, user, person, repository_names):
826+ """See `IGitRepositorySet`."""
827+ if user is None:
828+ return dict()
829+ lookup = getUtility(IGitLookup)
830+ visible_repositories = []
831+ for name in repository_names:
832+ repository = lookup.getByUniqueName(name)
833+ try:
834+ if (repository is not None
835+ and repository.visibleByUser(user)
836+ and repository.visibleByUser(person)):
837+ visible_repositories.append(repository.unique_name)
838+ except Unauthorized:
839+ # We don't include repositories user cannot see.
840+ pass
841+ return {
842+ 'person_name': person.displayname,
843+ 'visible_repositories': visible_repositories,
844+ }
845+
846 def getDefaultRepository(self, target):
847 """See `IGitRepositorySet`."""
848 clauses = [GitRepository.target_default == True]
849
850=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
851--- lib/lp/code/model/tests/test_gitrepository.py 2015-04-21 23:05:48 +0000
852+++ lib/lp/code/model/tests/test_gitrepository.py 2015-04-29 12:39:54 +0000
853@@ -1216,6 +1216,80 @@
854 public_repositories + [private_repository],
855 self.repository_set.getRepositories(other_person, project))
856
857+ def test_getRepositoryVisibilityInfo_empty_repository_names(self):
858+ # If repository_names is empty, getRepositoryVisibilityInfo returns
859+ # an empty visible_repositories list.
860+ person = self.factory.makePerson(name="fred")
861+ info = self.repository_set.getRepositoryVisibilityInfo(
862+ person, person, repository_names=[])
863+ self.assertEqual("Fred", info["person_name"])
864+ self.assertEqual([], info["visible_repositories"])
865+
866+ def test_getRepositoryVisibilityInfo(self):
867+ person = self.factory.makePerson(name="fred")
868+ owner = self.factory.makePerson()
869+ visible_repository = self.factory.makeGitRepository()
870+ invisible_repository = self.factory.makeGitRepository(
871+ owner=owner, information_type=InformationType.USERDATA)
872+ invisible_name = removeSecurityProxy(invisible_repository).unique_name
873+ repositories = [visible_repository.unique_name, invisible_name]
874+
875+ with person_logged_in(owner):
876+ info = self.repository_set.getRepositoryVisibilityInfo(
877+ owner, person, repository_names=repositories)
878+ self.assertEqual("Fred", info["person_name"])
879+ self.assertEqual(
880+ [visible_repository.unique_name], info["visible_repositories"])
881+
882+ def test_getRepositoryVisibilityInfo_unauthorised_user(self):
883+ # If the user making the API request cannot see one of the
884+ # repositories, that repository is not included in the results.
885+ person = self.factory.makePerson(name="fred")
886+ owner = self.factory.makePerson()
887+ visible_repository = self.factory.makeGitRepository()
888+ invisible_repository = self.factory.makeGitRepository(
889+ owner=owner, information_type=InformationType.USERDATA)
890+ invisible_name = removeSecurityProxy(invisible_repository).unique_name
891+ repositories = [visible_repository.unique_name, invisible_name]
892+
893+ someone = self.factory.makePerson()
894+ with person_logged_in(someone):
895+ info = self.repository_set.getRepositoryVisibilityInfo(
896+ someone, person, repository_names=repositories)
897+ self.assertEqual("Fred", info["person_name"])
898+ self.assertEqual(
899+ [visible_repository.unique_name], info["visible_repositories"])
900+
901+ def test_getRepositoryVisibilityInfo_anonymous(self):
902+ # Anonymous users are not allowed to see any repository visibility
903+ # information, even if the repository they are querying about is
904+ # public.
905+ person = self.factory.makePerson(name="fred")
906+ owner = self.factory.makePerson()
907+ visible_repository = self.factory.makeGitRepository(owner=owner)
908+ repositories = [visible_repository.unique_name]
909+
910+ with person_logged_in(owner):
911+ info = self.repository_set.getRepositoryVisibilityInfo(
912+ None, person, repository_names=repositories)
913+ self.assertEqual({}, info)
914+
915+ def test_getRepositoryVisibilityInfo_invalid_repository_name(self):
916+ # If an invalid repository name is specified, it is not included.
917+ person = self.factory.makePerson(name="fred")
918+ owner = self.factory.makePerson()
919+ visible_repository = self.factory.makeGitRepository(owner=owner)
920+ repositories = [
921+ visible_repository.unique_name,
922+ "invalid_repository_name"]
923+
924+ with person_logged_in(owner):
925+ info = self.repository_set.getRepositoryVisibilityInfo(
926+ owner, person, repository_names=repositories)
927+ self.assertEqual("Fred", info["person_name"])
928+ self.assertEqual(
929+ [visible_repository.unique_name], info["visible_repositories"])
930+
931 def test_setDefaultRepository_refuses_person(self):
932 # setDefaultRepository refuses if the target is a person.
933 person = self.factory.makePerson()
934
935=== modified file 'lib/lp/code/templates/branchmergeproposal-resubmit.pt'
936--- lib/lp/code/templates/branchmergeproposal-resubmit.pt 2010-11-04 16:56:35 +0000
937+++ lib/lp/code/templates/branchmergeproposal-resubmit.pt 2015-04-29 12:39:54 +0000
938@@ -24,7 +24,7 @@
939 </div>
940 </div>
941
942- <div id="source-revisions">
943+ <div id="source-revisions" tal:condition="context/source_branch">
944 <tal:history-available condition="context/source_branch/revision_count"
945 define="branch context/source_branch;
946 revisions view/unlanded_revisions">
947
948=== modified file 'lib/lp/code/templates/gitref-index.pt'
949--- lib/lp/code/templates/gitref-index.pt 2015-03-19 17:04:22 +0000
950+++ lib/lp/code/templates/gitref-index.pt 2015-04-29 12:39:54 +0000
951@@ -17,6 +17,13 @@
952 <div metal:fill-slot="main">
953
954 <div class="yui-g">
955+ <div id="ref-relations" class="portlet">
956+ <tal:ref-pending-merges
957+ replace="structure context/@@++ref-pending-merges" />
958+ </div>
959+ </div>
960+
961+ <div class="yui-g">
962 <div id="ref-info" class="portlet">
963 <h2>Branch information</h2>
964 <div class="two-column-list">
965
966=== added file 'lib/lp/code/templates/gitref-pending-merges.pt'
967--- lib/lp/code/templates/gitref-pending-merges.pt 1970-01-01 00:00:00 +0000
968+++ lib/lp/code/templates/gitref-pending-merges.pt 2015-04-29 12:39:54 +0000
969@@ -0,0 +1,43 @@
970+<div
971+ xmlns:tal="http://xml.zope.org/namespaces/tal"
972+ xmlns:metal="http://xml.zope.org/namespaces/metal"
973+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
974+ tal:define="
975+ context_menu view/context/menu:context;
976+ features request/features"
977+ tal:condition="view/show_merge_links">
978+
979+ <h3>Branch merges</h3>
980+ <div id="merge-links"
981+ class="actions">
982+ <div id="merge-summary">
983+
984+ <div id="landing-candidates"
985+ tal:condition="view/landing_candidates">
986+ <img src="/@@/merge-proposal-icon" />
987+ <a href="+activereviews" tal:content="structure view/landing_candidate_count_text">
988+ 1 branch
989+ </a>
990+ proposed for merging into this one.
991+
992+ </div>
993+
994+ <div id="dependent-landings" tal:condition="view/dependent_landings">
995+ <img src="/@@/merge-proposal-icon" />
996+ <a href="+merges" tal:content="structure view/dependent_landing_count_text">
997+ 1 branch
998+ </a>
999+ dependent on this one.
1000+ </div>
1001+
1002+ <div id="landing-targets" tal:condition="view/landing_targets">
1003+ <tal:landing-candidates repeat="mergeproposal view/landing_targets">
1004+ <tal:merge-fragment
1005+ tal:replace="structure mergeproposal/@@+summary-fragment"/>
1006+ </tal:landing-candidates>
1007+ </div>
1008+
1009+ </div>
1010+ </div>
1011+
1012+</div>
1013
1014=== modified file 'lib/lp/testing/factory.py'
1015--- lib/lp/testing/factory.py 2015-04-22 16:11:40 +0000
1016+++ lib/lp/testing/factory.py 2015-04-29 12:39:54 +0000
1017@@ -1747,7 +1747,8 @@
1018 u"type": GitObjectType.COMMIT,
1019 }
1020 for path in paths}
1021- return repository.createOrUpdateRefs(refs_info, get_objects=True)
1022+ return removeSecurityProxy(repository).createOrUpdateRefs(
1023+ refs_info, get_objects=True)
1024
1025 def makeBug(self, target=None, owner=None, bug_watch_url=None,
1026 information_type=None, date_closed=None, title=None,