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

Proposed by Colin Watson
Status: Merged
Merged at revision: 17470
Proposed branch: lp:~cjwatson/launchpad/git-mp-register
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-mp-basic-browser
Diff against target: 1162 lines (+739/-139)
7 files modified
lib/lp/app/widgets/suggestion.py (+84/-8)
lib/lp/app/widgets/tests/test_suggestion.py (+97/-8)
lib/lp/code/browser/configure.zcml (+9/-0)
lib/lp/code/browser/gitref.py (+218/-0)
lib/lp/code/browser/tests/test_branchmergeproposal.py (+245/-123)
lib/lp/code/templates/gitref-pending-merges.pt (+5/-0)
lib/lp/code/templates/gitref-register-merge.pt (+81/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-mp-register
Reviewer Review Type Date Requested Status
William Grant (community) code Approve
Review via email: mp+257676@code.launchpad.net

Commit message

Add a basic GitRef:+register-merge view.

Description of the change

Add a basic GitRef:+register-merge view.

The repository/path split in the UI is pretty gross, and some JavaScript doesn't work yet, but it should do the job until we have time to improve it.

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

Just one niggle.

review: Approve (code)
Revision history for this message
William Grant (wgrant) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/widgets/suggestion.py'
2--- lib/lp/app/widgets/suggestion.py 2013-04-10 08:05:17 +0000
3+++ lib/lp/app/widgets/suggestion.py 2015-04-30 12:30:35 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Widgets related to IBranch."""
10@@ -7,6 +7,7 @@
11 __all__ = [
12 'RecipeOwnerWidget',
13 'TargetBranchWidget',
14+ 'TargetGitRepositoryWidget',
15 ]
16
17
18@@ -32,6 +33,8 @@
19 )
20
21 from lp.app.widgets.itemswidgets import LaunchpadRadioWidget
22+from lp.code.interfaces.gitcollection import IGitCollection
23+from lp.code.interfaces.gitrepository import IGitRepositorySet
24 from lp.services.webapp import canonical_url
25 from lp.services.webapp.escaping import (
26 html_escape,
27@@ -259,15 +262,88 @@
28 # radio buttons that is not a hyperlink in order to select the radio
29 # button. It was decided not to have the entire text as a link, but
30 # instead to have a separate link to the branch details.
31- text = '%s (<a href="%s">branch details</a>)' % (
32- html_escape(branch.displayname),
33- html_escape(canonical_url(branch)))
34+ text = u'%s (<a href="%s">branch details</a>)'
35 # If the branch is the development focus, say so.
36 if branch == self.context.context.target.default_merge_target:
37- text = text + "&ndash; <em>development focus</em>"
38- label = u'<label for="%s" style="font-weight: normal">%s</label>' % (
39- self._optionId(index), text)
40- return structured(label)
41+ text += u"&ndash; <em>development focus</em>"
42+ label = (
43+ u'<label for="%s" style="font-weight: normal">' + text +
44+ u'</label>')
45+ return structured(
46+ label, self._optionId(index), branch.displayname,
47+ canonical_url(branch))
48+
49+ def _autoselectOther(self):
50+ """Select "other" on keypress."""
51+ on_key_press = "selectWidget('%s', event);" % self._otherId()
52+ self.other_selection_widget.onKeyPress = on_key_press
53+
54+
55+class TargetGitRepositoryWidget(SuggestionWidget):
56+ """Widget for selecting a target Git repository.
57+
58+ The default repository for a new Git merge proposal should be the
59+ default repository for the target if there is one.
60+
61+ Also in the initial radio button selector are other repositories for the
62+ target that the repository owner has specified as target repositories
63+ for other merge proposals.
64+
65+ Finally there is an "other" button that gets the user to use the normal
66+ branch selector.
67+ """
68+
69+ @staticmethod
70+ def _generateSuggestionVocab(repository, full_vocabulary):
71+ """Generate the vocabulary for the radio buttons.
72+
73+ The generated vocabulary contains the default repository for the
74+ target if there is one, and also any other repositories that the
75+ user has specified recently as a target for a proposed merge.
76+ """
77+ default_target = getUtility(IGitRepositorySet).getDefaultRepository(
78+ repository.target)
79+ logged_in_user = getUtility(ILaunchBag).user
80+ since = datetime.now(utc) - timedelta(days=90)
81+ collection = IGitCollection(repository.target).targetedBy(
82+ logged_in_user, since)
83+ collection = collection.visibleByUser(logged_in_user)
84+ # May actually need some eager loading, but the API isn't fine grained
85+ # yet.
86+ repositories = collection.getRepositories(eager_load=False).config(
87+ distinct=True)
88+ target_repositories = list(repositories.config(limit=5))
89+ # If there is a default repository, make sure it is always shown,
90+ # and as the first item.
91+ if default_target is not None:
92+ if default_target in target_repositories:
93+ target_repositories.remove(default_target)
94+ target_repositories.insert(0, default_target)
95+
96+ terms = []
97+ for repository in target_repositories:
98+ terms.append(SimpleTerm(repository, repository.unique_name))
99+
100+ return SimpleVocabulary(terms)
101+
102+ def _renderSuggestionLabel(self, repository, index):
103+ """Render a label for the option based on a repository."""
104+ # To aid usability there needs to be some text connected with the
105+ # radio buttons that is not a hyperlink in order to select the radio
106+ # button. It was decided not to have the entire text as a link, but
107+ # instead to have a separate link to the repository details.
108+ text = u'%s (<a href="%s">repository details</a>)'
109+ # If the repository is the default for the target, say so.
110+ default_target = getUtility(IGitRepositorySet).getDefaultRepository(
111+ repository.target)
112+ if repository == default_target:
113+ text += u"&ndash; <em>default repository</em>"
114+ label = (
115+ u'<label for="%s" style="font-weight: normal">' + text +
116+ u'</label>')
117+ return structured(
118+ label, self._optionId(index), repository.display_name,
119+ canonical_url(repository))
120
121 def _autoselectOther(self):
122 """Select "other" on keypress."""
123
124=== modified file 'lib/lp/app/widgets/tests/test_suggestion.py'
125--- lib/lp/app/widgets/tests/test_suggestion.py 2015-04-21 14:32:31 +0000
126+++ lib/lp/app/widgets/tests/test_suggestion.py 2015-04-30 12:30:35 +0000
127@@ -1,4 +1,4 @@
128-# Copyright 2011 Canonical Ltd. This software is licensed under the
129+# Copyright 2011-2015 Canonical Ltd. This software is licensed under the
130 # GNU Affero General Public License version 3 (see the file LICENSE).
131
132 __metaclass__ = type
133@@ -12,7 +12,10 @@
134
135 from pytz import utc
136 from testtools.matchers import DocTestMatches
137-from zope.component import provideUtility
138+from zope.component import (
139+ getUtility,
140+ provideUtility,
141+ )
142 from zope.interface import implements
143 from zope.schema import Choice
144 from zope.schema.interfaces import IVocabularyFactory
145@@ -24,7 +27,13 @@
146 from lp.app.widgets.suggestion import (
147 SuggestionWidget,
148 TargetBranchWidget,
149- )
150+ TargetGitRepositoryWidget,
151+ )
152+from lp.code.interfaces.gitrepository import (
153+ GIT_FEATURE_FLAG,
154+ IGitRepositorySet,
155+ )
156+from lp.services.features.testing import FeatureFixture
157 from lp.services.webapp.servers import LaunchpadTestRequest
158 from lp.services.webapp.vocabulary import (
159 FilteredVocabularyBase,
160@@ -97,23 +106,23 @@
161
162 def test__renderLabel_unsafe_content(self):
163 # Render label escapes unsafe markup.
164- strutured_string = self.widget._renderLabel(self.UNSAFE_TERM.title, 2)
165+ structured_string = self.widget._renderLabel(self.UNSAFE_TERM.title, 2)
166 expected_item_0 = (
167 """<label for="field.test_field.2"
168 ...>&lt;unsafe&gt; &amp;nbsp; title</label>""")
169 self.assertThat(
170- strutured_string.escapedtext,
171+ structured_string.escapedtext,
172 DocTestMatches(expected_item_0, self.doctest_opts))
173
174 def test__renderSuggestionLabel_unsafe_content(self):
175 # Render sugestion label escapes unsafe markup.
176- strutured_string = self.widget._renderSuggestionLabel(
177+ structured_string = self.widget._renderSuggestionLabel(
178 self.UNSAFE_OBJECT, 2)
179 expected_item_0 = (
180 """<label for="field.test_field.2"
181 ...>&lt;unsafe&gt; &amp;nbsp; title</label>""")
182 self.assertThat(
183- strutured_string.escapedtext,
184+ structured_string.escapedtext,
185 DocTestMatches(expected_item_0, self.doctest_opts))
186
187 def test_renderItems(self):
188@@ -142,7 +151,7 @@
189
190 def make_target_branch_widget(branch):
191 """Given a branch, return a widget for selecting where to land it."""
192- choice = Choice(vocabulary='Branch').bind(branch)
193+ choice = Choice(__name__='test_field', vocabulary='Branch').bind(branch)
194 request = LaunchpadTestRequest()
195 return TargetBranchWidget(choice, None, request)
196
197@@ -151,6 +160,9 @@
198 """Test the TargetBranchWidget class."""
199
200 layer = DatabaseFunctionalLayer
201+ doctest_opts = (
202+ doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF |
203+ doctest.ELLIPSIS)
204
205 def makeBranchAndOldMergeProposal(self, timedelta):
206 """Make an old merge proposal and a branch with the same target."""
207@@ -173,3 +185,80 @@
208 timedelta(days=91))
209 widget = make_target_branch_widget(source)
210 self.assertNotIn(target, widget.suggestion_vocab)
211+
212+ def test__renderSuggestionLabel(self):
213+ """Branches have a reasonable suggestion label."""
214+ target, source = self.makeBranchAndOldMergeProposal(
215+ timedelta(days=1))
216+ login_person(target.product.owner)
217+ target.product.development_focus.branch = target
218+ widget = make_target_branch_widget(source)
219+ expected = (
220+ """<label for="field.test_field.2"
221+ ...>... (<a href="...">branch details</a>)&ndash;
222+ <em>development focus</em></label>""")
223+ structured_string = widget._renderSuggestionLabel(target, 2)
224+ self.assertThat(
225+ structured_string.escapedtext,
226+ DocTestMatches(expected, self.doctest_opts))
227+
228+
229+def make_target_git_repository_widget(repository):
230+ """Given a Git repository, return a widget for selecting where to land
231+ it."""
232+ choice = Choice(__name__='test_field', vocabulary='GitRepository')
233+ choice = choice.bind(repository)
234+ request = LaunchpadTestRequest()
235+ return TargetGitRepositoryWidget(choice, None, request)
236+
237+
238+class TestTargetGitRepositoryWidget(TestCaseWithFactory):
239+ """Test the TargetGitRepositoryWidget class."""
240+
241+ layer = DatabaseFunctionalLayer
242+ doctest_opts = (
243+ doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF |
244+ doctest.ELLIPSIS)
245+
246+ def setUp(self):
247+ super(TestTargetGitRepositoryWidget, self).setUp()
248+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
249+
250+ def makeRepositoryAndOldMergeProposal(self, timedelta):
251+ """Make an old merge proposal and a repository with the same target."""
252+ bmp = self.factory.makeBranchMergeProposalForGit(
253+ date_created=datetime.now(utc) - timedelta)
254+ login_person(bmp.registrant)
255+ target = bmp.target_git_repository
256+ return target, self.factory.makeGitRepository(target=target.target)
257+
258+ def test_recent_target(self):
259+ """Targets for proposals newer than 90 days are included."""
260+ target, source = self.makeRepositoryAndOldMergeProposal(
261+ timedelta(days=89))
262+ widget = make_target_git_repository_widget(source)
263+ self.assertIn(target, widget.suggestion_vocab)
264+
265+ def test_stale_target(self):
266+ """Targets for proposals older than 90 days are not considered."""
267+ target, source = self.makeRepositoryAndOldMergeProposal(
268+ timedelta(days=91))
269+ widget = make_target_git_repository_widget(source)
270+ self.assertNotIn(target, widget.suggestion_vocab)
271+
272+ def test__renderSuggestionLabel(self):
273+ """Git repositories have a reasonable suggestion label."""
274+ target, source = self.makeRepositoryAndOldMergeProposal(
275+ timedelta(days=1))
276+ login_person(target.target.owner)
277+ getUtility(IGitRepositorySet).setDefaultRepository(
278+ target.target, target)
279+ widget = make_target_git_repository_widget(source)
280+ expected = (
281+ """<label for="field.test_field.2"
282+ ...>... (<a href="...">repository details</a>)&ndash;
283+ <em>default repository</em></label>""")
284+ structured_string = widget._renderSuggestionLabel(target, 2)
285+ self.assertThat(
286+ structured_string.escapedtext,
287+ DocTestMatches(expected, self.doctest_opts))
288
289=== modified file 'lib/lp/code/browser/configure.zcml'
290--- lib/lp/code/browser/configure.zcml 2015-04-28 16:39:15 +0000
291+++ lib/lp/code/browser/configure.zcml 2015-04-30 12:30:35 +0000
292@@ -838,6 +838,15 @@
293 permission="zope.Public"
294 name="+ref-listing"
295 template="../templates/gitref-listing.pt"/>
296+ <browser:page
297+ for="lp.code.interfaces.gitref.IGitRef"
298+ class="lp.code.browser.gitref.GitRefRegisterMergeProposalView"
299+ name="+register-merge"
300+ permission="launchpad.AnyPerson"
301+ template="../templates/gitref-register-merge.pt"/>
302+ <browser:menus
303+ classes="GitRefContextMenu"
304+ module="lp.code.browser.gitref"/>
305
306 <browser:defaultView
307 for="lp.code.interfaces.gitsubscription.IGitSubscription"
308
309=== modified file 'lib/lp/code/browser/gitref.py'
310--- lib/lp/code/browser/gitref.py 2015-04-28 16:39:15 +0000
311+++ lib/lp/code/browser/gitref.py 2015-04-30 12:30:35 +0000
312@@ -6,19 +6,50 @@
313 __metaclass__ = type
314
315 __all__ = [
316+ 'GitRefContextMenu',
317 'GitRefNavigation',
318+ 'GitRefRegisterMergeProposalView',
319 'GitRefView',
320 ]
321
322+import json
323+
324+from lazr.restful.interface import copy_field
325+from zope.component import getUtility
326+from zope.formlib.widgets import TextAreaWidget
327+from zope.interface import Interface
328+from zope.publisher.interfaces import NotFound
329+from zope.schema import (
330+ Bool,
331+ Choice,
332+ Text,
333+ TextLine,
334+ )
335+
336+from lp import _
337+from lp.app.browser.launchpadform import (
338+ action,
339+ custom_widget,
340+ LaunchpadFormView,
341+ )
342+from lp.app.widgets.suggestion import TargetGitRepositoryWidget
343 from lp.code.browser.branchmergeproposal import (
344 latest_proposals_for_each_branch,
345 )
346+from lp.code.errors import InvalidBranchMergeProposal
347+from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
348+from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
349 from lp.code.interfaces.gitref import IGitRef
350+from lp.code.interfaces.gitrepository import IGitRepositorySet
351 from lp.code.model.gitrepository import GitRepository
352 from lp.services.database.bulk import load_related
353+from lp.services.helpers import english_list
354 from lp.services.propertycache import cachedproperty
355 from lp.services.webapp import (
356+ canonical_url,
357+ ContextMenu,
358 LaunchpadView,
359+ Link,
360 Navigation,
361 stepthrough,
362 )
363@@ -42,6 +73,19 @@
364 return proposal
365
366
367+class GitRefContextMenu(ContextMenu):
368+ """Context menu for Git references."""
369+
370+ usedfor = IGitRef
371+ facet = 'branches'
372+ links = ['register_merge']
373+
374+ def register_merge(self):
375+ text = 'Propose for merging'
376+ enabled = self.context.namespace.supports_merge_proposals
377+ return Link('+register-merge', text, icon='add', enabled=enabled)
378+
379+
380 class GitRefView(LaunchpadView):
381
382 @property
383@@ -110,3 +154,177 @@
384 @cachedproperty
385 def dependent_landing_count_text(self):
386 return self._getBranchCountText(len(self.dependent_landings))
387+
388+
389+class GitRefRegisterMergeProposalSchema(Interface):
390+ """The schema to define the form for registering a new merge proposal."""
391+
392+ target_git_repository = Choice(
393+ title=_("Target repository"),
394+ vocabulary="GitRepository", required=True, readonly=True,
395+ description=_("The repository that the source will be merged into."))
396+
397+ target_git_path = TextLine(
398+ title=_("Target reference path"), required=True, readonly=True,
399+ description=_(
400+ "The reference within the target repository that the source will "
401+ "be merged into."))
402+
403+ prerequisite_git_repository = Choice(
404+ title=_("Prerequisite repository"),
405+ vocabulary="GitRepository", required=False, readonly=True,
406+ description=_("The repository that the source will be merged into."))
407+
408+ prerequisite_git_path = TextLine(
409+ title=_("Prerequisite reference path"), required=False, readonly=True,
410+ description=_(
411+ "The reference within the target repository that the source will "
412+ "be merged into."))
413+
414+ comment = Text(
415+ title=_('Description of the Change'), required=False,
416+ description=_('Describe what changes your branch introduces, '
417+ 'what bugs it fixes, or what features it implements. '
418+ 'Ideally include rationale and how to test.'))
419+
420+ reviewer = copy_field(
421+ ICodeReviewVoteReference['reviewer'], required=False)
422+
423+ review_type = copy_field(
424+ ICodeReviewVoteReference['review_type'],
425+ description=u'Lowercase keywords describing the type of review you '
426+ 'would like to be performed.')
427+
428+ commit_message = IBranchMergeProposal['commit_message']
429+
430+ needs_review = Bool(
431+ title=_("Needs review"), required=True, default=True,
432+ description=_(
433+ "Is the proposal ready for review now?"))
434+
435+
436+class GitRefRegisterMergeProposalView(LaunchpadFormView):
437+ """The view to register new Git merge proposals."""
438+
439+ schema = GitRefRegisterMergeProposalSchema
440+ for_input = True
441+
442+ custom_widget('target_git_repository', TargetGitRepositoryWidget)
443+ custom_widget('comment', TextAreaWidget, cssClass='comment-text')
444+
445+ page_title = label = 'Propose for merging'
446+
447+ @property
448+ def cancel_url(self):
449+ return canonical_url(self.context)
450+
451+ def initialize(self):
452+ """Show a 404 if the repository namespace doesn't support proposals."""
453+ if not self.context.namespace.supports_merge_proposals:
454+ raise NotFound(self.context, '+register-merge')
455+ super(GitRefRegisterMergeProposalView, self).initialize()
456+
457+ @action('Propose Merge', name='register',
458+ failure=LaunchpadFormView.ajax_failure_handler)
459+ def register_action(self, action, data):
460+ """Register the new merge proposal."""
461+
462+ registrant = self.user
463+ source_ref = self.context
464+ target_ref = data['target_git_repository'].getRefByPath(
465+ data['target_git_path'])
466+ if (data.get('prerequisite_git_repository') is not None and
467+ data.get('prerequisite_git_path') is not None):
468+ prerequisite_ref = (
469+ data['prerequisite_git_repository'].getRefByPath(
470+ data['prerequisite_git_path']))
471+ else:
472+ prerequisite_ref = None
473+
474+ review_requests = []
475+ reviewer = data.get('reviewer')
476+ review_type = data.get('review_type')
477+ if reviewer is None:
478+ reviewer = target_ref.code_reviewer
479+ if reviewer is not None:
480+ review_requests.append((reviewer, review_type))
481+
482+ repository_names = [
483+ ref.repository.unique_name for ref in (source_ref, target_ref)]
484+ repository_set = getUtility(IGitRepositorySet)
485+ visibility_info = repository_set.getRepositoryVisibilityInfo(
486+ self.user, reviewer, repository_names)
487+ visible_repositories = list(visibility_info['visible_repositories'])
488+ if self.request.is_ajax and len(visible_repositories) < 2:
489+ self.request.response.setStatus(400, "Repository Visibility")
490+ self.request.response.setHeader(
491+ 'Content-Type', 'application/json')
492+ return json.dumps({
493+ 'person_name': visibility_info['person_name'],
494+ 'repositories_to_check': repository_names,
495+ 'visible_repositories': visible_repositories,
496+ })
497+
498+ try:
499+ proposal = source_ref.addLandingTarget(
500+ registrant=registrant, merge_target=target_ref,
501+ merge_prerequisite=prerequisite_ref,
502+ needs_review=data['needs_review'],
503+ description=data.get('comment'),
504+ review_requests=review_requests,
505+ commit_message=data.get('commit_message'))
506+ if len(visible_repositories) < 2:
507+ invisible_repositories = [
508+ ref.repository.unique_name
509+ for ref in (source_ref, target_ref)
510+ if ref.repository.unique_name not in visible_repositories]
511+ self.request.response.addNotification(
512+ 'To ensure visibility, %s is now subscribed to: %s'
513+ % (visibility_info['person_name'],
514+ english_list(invisible_repositories)))
515+ # Success so we do a client redirect to the new mp page.
516+ if self.request.is_ajax:
517+ self.request.response.setStatus(201)
518+ self.request.response.setHeader(
519+ 'Location', canonical_url(proposal))
520+ return None
521+ else:
522+ self.next_url = canonical_url(proposal)
523+ except InvalidBranchMergeProposal as error:
524+ self.addError(str(error))
525+
526+ def validate(self, data):
527+ source_ref = self.context
528+ target_repository = data['target_git_repository']
529+ target_ref = target_repository.getRefByPath(data['target_git_path'])
530+ if target_ref is None:
531+ self.setFieldError(
532+ 'target_git_path',
533+ "The target path must be the path of a reference in the "
534+ "target repository.")
535+ elif source_ref == target_ref:
536+ self.setFieldError(
537+ 'target_git_path',
538+ "The target repository and path together cannot be the same "
539+ "as the source repository and path.")
540+ elif not target_repository.isRepositoryMergeable(self.context):
541+ self.setFieldError(
542+ 'target_git_repository',
543+ "This repository is not mergeable into %s." %
544+ target_repository.identity)
545+ elif (data.get('prerequisite_git_repository') is not None and
546+ data.get('prerequisite_git_path') is not None):
547+ prerequisite_repository = data['prerequisite_git_repository']
548+ prerequisite_ref = prerequisite_repository.getRefByPath(
549+ data['prerequisite_git_path'])
550+ if prerequisite_ref is None:
551+ self.setFieldError(
552+ 'prerequisite_git_path',
553+ "The prerequisite path must be the path of a reference in "
554+ "the prerequisite repository.")
555+ elif not target_repository.isRepositoryMergeable(
556+ prerequisite_repository):
557+ self.setFieldError(
558+ 'prerequisite_git_repository',
559+ "This repository is not mergeable into %s." %
560+ target_repository.identity)
561
562=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
563--- lib/lp/code/browser/tests/test_branchmergeproposal.py 2015-04-28 16:39:15 +0000
564+++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2015-04-30 12:30:35 +0000
565@@ -1,4 +1,4 @@
566-# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
567+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
568 # GNU Affero General Public License version 3 (see the file LICENSE).
569
570
571@@ -42,6 +42,7 @@
572 latest_proposals_for_each_branch,
573 )
574 from lp.code.browser.codereviewcomment import CodeReviewDisplayComment
575+from lp.code.browser.gitref import GitRefRegisterMergeProposalView
576 from lp.code.enums import (
577 BranchMergeProposalStatus,
578 CodeReviewVote,
579@@ -402,41 +403,28 @@
580 view.render()
581
582
583-class TestRegisterBranchMergeProposalView(BrowserTestCase):
584+class TestRegisterBranchMergeProposalViewMixin:
585 """Test the merge proposal registration view."""
586
587 layer = LaunchpadFunctionalLayer
588
589 def setUp(self):
590- TestCaseWithFactory.setUp(self)
591- self.source_branch = self.factory.makeProductBranch()
592+ super(TestRegisterBranchMergeProposalViewMixin, self).setUp()
593+ self.source_branch = self._makeBranch()
594 self.user = self.factory.makePerson()
595 login_person(self.user)
596
597- def _makeTargetBranch(self, **kwargs):
598- return self.factory.makeProductBranch(
599- product=self.source_branch.product, **kwargs)
600-
601 def _makeTargetBranchWithReviewer(self):
602 albert = self.factory.makePerson(name='albert')
603- target_branch = self.factory.makeProductBranch(
604- reviewer=albert, product=self.source_branch.product)
605+ target_branch = self._makeTargetBranch(reviewer=albert)
606 return target_branch, albert
607
608- def _createView(self, request=None):
609- # Construct the view and initialize it.
610- if not request:
611- request = LaunchpadTestRequest()
612- view = RegisterBranchMergeProposalView(self.source_branch, request)
613- view.initialize()
614- return view
615-
616 def _getSourceProposal(self, target_branch):
617 # There will only be one proposal.
618 landing_targets = list(self.source_branch.landing_targets)
619 self.assertEqual(1, len(landing_targets))
620 proposal = landing_targets[0]
621- self.assertEqual(target_branch, proposal.target_branch)
622+ self.assertEqual(target_branch, proposal.merge_target)
623 return proposal
624
625 def assertOnePendingReview(self, proposal, reviewer, review_type=None):
626@@ -458,72 +446,12 @@
627 # therefore be set to the branch owner.
628 target_branch = self._makeTargetBranch()
629 view = self._createView()
630- view.register_action.success(
631- {'target_branch': target_branch,
632- 'needs_review': True})
633+ view.register_action.success(self._getFormValues(
634+ target_branch, {'needs_review': True}))
635 proposal = self._getSourceProposal(target_branch)
636 self.assertOnePendingReview(proposal, target_branch.owner)
637 self.assertIs(None, proposal.description)
638
639- def test_register_ajax_request_with_confirmation(self):
640- # Ajax submits return json data containing info about what the visible
641- # branches are if they are not all visible to the reviewer.
642-
643- # Make a branch the reviewer cannot see.
644- owner = self.factory.makePerson()
645- target_branch = self._makeTargetBranch(
646- owner=owner, information_type=InformationType.USERDATA)
647- reviewer = self.factory.makePerson()
648- extra = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
649- request = LaunchpadTestRequest(
650- method='POST', principal=owner, **extra)
651- view = self._createView(request=request)
652- with person_logged_in(owner):
653- branches_to_check = [self.source_branch.unique_name,
654- target_branch.unique_name]
655- expected_data = {
656- 'person_name': reviewer.displayname,
657- 'branches_to_check': branches_to_check,
658- 'visible_branches': [self.source_branch.unique_name]}
659- result_data = view.register_action.success(
660- {'target_branch': target_branch,
661- 'reviewer': reviewer,
662- 'needs_review': True})
663- self.assertEqual(
664- '400 Branch Visibility',
665- view.request.response.getStatusString())
666- self.assertEqual(expected_data, simplejson.loads(result_data))
667-
668- def test_register_ajax_request_with_validation_errors(self):
669- # Ajax submits where there is a validation error in the submitted data
670- # return the expected json response containing the error info.
671- owner = self.factory.makePerson()
672- target_branch = self._makeTargetBranch(
673- owner=owner, information_type=InformationType.USERDATA)
674- extra = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
675- with person_logged_in(owner):
676- request = LaunchpadTestRequest(
677- method='POST', principal=owner,
678- form={
679- 'field.actions.register': 'Propose Merge',
680- 'field.target_branch.target_branch':
681- target_branch.unique_name},
682- **extra)
683- view = create_initialized_view(
684- target_branch,
685- name='+register-merge',
686- request=request)
687- self.assertEqual(
688- '400 Validation', view.request.response.getStatusString())
689- self.assertEqual(
690- {'error_summary': 'There is 1 error.',
691- 'errors': {
692- 'field.target_branch':
693- ('The target branch cannot be the same as the '
694- 'source branch.')},
695- 'form_wide_errors': []},
696- simplejson.loads(view.form_result))
697-
698 def test_register_ajax_request_with_no_confirmation(self):
699 # Ajax submits where there is no confirmation required return a 201
700 # with the new location.
701@@ -535,10 +463,11 @@
702 method='POST', principal=owner, **extra)
703 view = self._createView(request=request)
704 with person_logged_in(owner):
705- result_data = view.register_action.success(
706- {'target_branch': target_branch,
707- 'reviewer': reviewer,
708- 'needs_review': True})
709+ result_data = view.register_action.success(self._getFormValues(
710+ target_branch, {
711+ 'reviewer': reviewer,
712+ 'needs_review': True,
713+ }))
714 self.assertEqual(None, result_data)
715 self.assertEqual(201, view.request.response.getStatus())
716 mp = target_branch.getMergeProposals()[0]
717@@ -550,9 +479,8 @@
718 # progress proposal.
719 target_branch = self._makeTargetBranch()
720 view = self._createView()
721- view.register_action.success(
722- {'target_branch': target_branch,
723- 'needs_review': False})
724+ view.register_action.success(self._getFormValues(
725+ target_branch, {'needs_review': False}))
726 proposal = self._getSourceProposal(target_branch)
727 self.assertEqual(
728 BranchMergeProposalStatus.WORK_IN_PROGRESS,
729@@ -562,10 +490,11 @@
730 # A commit message can also be set during the register process.
731 target_branch = self._makeTargetBranch()
732 view = self._createView()
733- view.register_action.success(
734- {'target_branch': target_branch,
735- 'needs_review': True,
736- 'commit_message': 'Fixed the bug!'})
737+ view.register_action.success(self._getFormValues(
738+ target_branch, {
739+ 'needs_review': True,
740+ 'commit_message': 'Fixed the bug!',
741+ }))
742 proposal = self._getSourceProposal(target_branch)
743 self.assertEqual('Fixed the bug!', proposal.commit_message)
744
745@@ -574,10 +503,11 @@
746 # proposal.
747 target_branch = self._makeTargetBranch()
748 view = self._createView()
749- view.register_action.success(
750- {'target_branch': target_branch,
751- 'comment': "This is the description.",
752- 'needs_review': True})
753+ view.register_action.success(self._getFormValues(
754+ target_branch, {
755+ 'comment': "This is the description.",
756+ 'needs_review': True,
757+ }))
758
759 proposal = self._getSourceProposal(target_branch)
760 self.assertOnePendingReview(proposal, target_branch.owner)
761@@ -589,10 +519,11 @@
762 target_branch = self._makeTargetBranch()
763 reviewer = self.factory.makePerson()
764 view = self._createView()
765- view.register_action.success(
766- {'target_branch': target_branch,
767- 'reviewer': reviewer,
768- 'needs_review': True})
769+ view.register_action.success(self._getFormValues(
770+ target_branch, {
771+ 'reviewer': reviewer,
772+ 'needs_review': True,
773+ }))
774
775 proposal = self._getSourceProposal(target_branch)
776 self.assertOnePendingReview(proposal, reviewer)
777@@ -604,11 +535,12 @@
778 target_branch = self._makeTargetBranch()
779 reviewer = self.factory.makePerson()
780 view = self._createView()
781- view.register_action.success(
782- {'target_branch': target_branch,
783- 'reviewer': reviewer,
784- 'review_type': 'god-like',
785- 'needs_review': True})
786+ view.register_action.success(self._getFormValues(
787+ target_branch, {
788+ 'reviewer': reviewer,
789+ 'review_type': 'god-like',
790+ 'needs_review': True,
791+ }))
792
793 proposal = self._getSourceProposal(target_branch)
794 self.assertOnePendingReview(proposal, reviewer, 'god-like')
795@@ -620,12 +552,13 @@
796 target_branch = self._makeTargetBranch()
797 reviewer = self.factory.makePerson()
798 view = self._createView()
799- view.register_action.success(
800- {'target_branch': target_branch,
801- 'reviewer': reviewer,
802- 'review_type': 'god-like',
803- 'comment': "This is the description.",
804- 'needs_review': True})
805+ view.register_action.success(self._getFormValues(
806+ target_branch, {
807+ 'reviewer': reviewer,
808+ 'review_type': 'god-like',
809+ 'comment': "This is the description.",
810+ 'needs_review': True,
811+ }))
812
813 proposal = self._getSourceProposal(target_branch)
814 self.assertOnePendingReview(proposal, reviewer, 'god-like')
815@@ -637,9 +570,8 @@
816 # has a reviewer so that reviewer should be used
817 target_branch, reviewer = self._makeTargetBranchWithReviewer()
818 view = self._createView()
819- view.register_action.success(
820- {'target_branch': target_branch,
821- 'needs_review': True})
822+ view.register_action.success(self._getFormValues(
823+ target_branch, {'needs_review': True}))
824 proposal = self._getSourceProposal(target_branch)
825 self.assertOnePendingReview(proposal, reviewer)
826 self.assertIs(None, proposal.description)
827@@ -649,16 +581,17 @@
828 # reviewer so that reviewer should be used.
829 target_branch, reviewer = self._makeTargetBranchWithReviewer()
830 view = self._createView()
831- view.register_action.success(
832- {'target_branch': target_branch,
833- 'review_type': 'god-like',
834- 'needs_review': True})
835+ view.register_action.success(self._getFormValues(
836+ target_branch, {
837+ 'review_type': 'god-like',
838+ 'needs_review': True,
839+ }))
840 proposal = self._getSourceProposal(target_branch)
841 self.assertOnePendingReview(proposal, reviewer, 'god-like')
842 self.assertIs(None, proposal.description)
843
844 def test_register_reviewer_not_hidden(self):
845- branch = self.factory.makeBranch()
846+ branch = self._makeBranch()
847 browser = self.getViewBrowser(branch, '+register-merge')
848 extra = Tag(
849 'extra', 'fieldset', attrs={'id': 'mergeproposal-extra-options'})
850@@ -675,10 +608,11 @@
851 reviewer = self.factory.makePerson()
852 with person_logged_in(owner):
853 view = self._createView()
854- view.register_action.success(
855- {'target_branch': target_branch,
856- 'reviewer': reviewer,
857- 'needs_review': True})
858+ view.register_action.success(self._getFormValues(
859+ target_branch, {
860+ 'reviewer': reviewer,
861+ 'needs_review': True,
862+ }))
863
864 (notification,) = view.request.response.notifications
865 self.assertThat(
866@@ -687,6 +621,194 @@
867 self.assertEqual(BrowserNotificationLevel.INFO, notification.level)
868
869
870+class TestRegisterBranchMergeProposalViewBzr(
871+ TestRegisterBranchMergeProposalViewMixin, BrowserTestCase):
872+ """Test the merge proposal registration view for Bazaar."""
873+
874+ def _makeBranch(self):
875+ return self.factory.makeProductBranch()
876+
877+ def _makeTargetBranch(self, **kwargs):
878+ return self.factory.makeProductBranch(
879+ product=self.source_branch.product, **kwargs)
880+
881+ def _makeTargetBranchWithReviewer(self):
882+ albert = self.factory.makePerson(name='albert')
883+ target_branch = self._makeTargetBranch(reviewer=albert)
884+ return target_branch, albert
885+
886+ def _createView(self, request=None):
887+ # Construct the view and initialize it.
888+ if not request:
889+ request = LaunchpadTestRequest()
890+ view = RegisterBranchMergeProposalView(self.source_branch, request)
891+ view.initialize()
892+ return view
893+
894+ @staticmethod
895+ def _getFormValues(target_branch, extras):
896+ values = {'target_branch': target_branch}
897+ values.update(extras)
898+ return values
899+
900+ def test_register_ajax_request_with_confirmation(self):
901+ # Ajax submits return json data containing info about what the visible
902+ # branches are if they are not all visible to the reviewer.
903+
904+ # Make a branch the reviewer cannot see.
905+ owner = self.factory.makePerson()
906+ target_branch = self._makeTargetBranch(
907+ owner=owner, information_type=InformationType.USERDATA)
908+ reviewer = self.factory.makePerson()
909+ extra = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
910+ request = LaunchpadTestRequest(
911+ method='POST', principal=owner, **extra)
912+ view = self._createView(request=request)
913+ with person_logged_in(owner):
914+ branches_to_check = [self.source_branch.unique_name,
915+ target_branch.unique_name]
916+ expected_data = {
917+ 'person_name': reviewer.displayname,
918+ 'branches_to_check': branches_to_check,
919+ 'visible_branches': [self.source_branch.unique_name]}
920+ result_data = view.register_action.success(self._getFormValues(
921+ target_branch, {
922+ 'reviewer': reviewer,
923+ 'needs_review': True,
924+ }))
925+ self.assertEqual(
926+ '400 Branch Visibility',
927+ view.request.response.getStatusString())
928+ self.assertEqual(expected_data, simplejson.loads(result_data))
929+
930+ def test_register_ajax_request_with_validation_errors(self):
931+ # Ajax submits where there is a validation error in the submitted data
932+ # return the expected json response containing the error info.
933+ owner = self.factory.makePerson()
934+ target_branch = self._makeTargetBranch(
935+ owner=owner, information_type=InformationType.USERDATA)
936+ extra = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
937+ with person_logged_in(owner):
938+ request = LaunchpadTestRequest(
939+ method='POST', principal=owner,
940+ form={
941+ 'field.actions.register': 'Propose Merge',
942+ 'field.target_branch.target_branch':
943+ target_branch.unique_name},
944+ **extra)
945+ view = create_initialized_view(
946+ target_branch,
947+ name='+register-merge',
948+ request=request)
949+ self.assertEqual(
950+ '400 Validation', view.request.response.getStatusString())
951+ self.assertEqual(
952+ {'error_summary': 'There is 1 error.',
953+ 'errors': {
954+ 'field.target_branch':
955+ ('The target branch cannot be the same as the '
956+ 'source branch.')},
957+ 'form_wide_errors': []},
958+ simplejson.loads(view.form_result))
959+
960+
961+class TestRegisterBranchMergeProposalViewGit(
962+ TestRegisterBranchMergeProposalViewMixin, BrowserTestCase):
963+ """Test the merge proposal registration view for Git."""
964+
965+ def setUp(self):
966+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
967+ super(TestRegisterBranchMergeProposalViewGit, self).setUp()
968+
969+ def _makeBranch(self):
970+ return self.factory.makeGitRefs()[0]
971+
972+ def _makeTargetBranch(self, **kwargs):
973+ return self.factory.makeGitRefs(
974+ target=self.source_branch.target, **kwargs)[0]
975+
976+ def _createView(self, request=None):
977+ # Construct the view and initialize it.
978+ if not request:
979+ request = LaunchpadTestRequest()
980+ view = GitRefRegisterMergeProposalView(self.source_branch, request)
981+ view.initialize()
982+ return view
983+
984+ @staticmethod
985+ def _getFormValues(target_branch, extras):
986+ values = {
987+ 'target_git_repository': target_branch.repository,
988+ 'target_git_path': target_branch.path,
989+ }
990+ values.update(extras)
991+ return values
992+
993+ def test_register_ajax_request_with_confirmation(self):
994+ # Ajax submits return json data containing info about what the visible
995+ # repositories are if they are not all visible to the reviewer.
996+
997+ # Make a branch the reviewer cannot see.
998+ owner = self.factory.makePerson()
999+ target_branch = self._makeTargetBranch(
1000+ owner=owner, information_type=InformationType.USERDATA)
1001+ reviewer = self.factory.makePerson()
1002+ extra = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
1003+ request = LaunchpadTestRequest(
1004+ method='POST', principal=owner, **extra)
1005+ view = self._createView(request=request)
1006+ with person_logged_in(owner):
1007+ repositories_to_check = [
1008+ self.source_branch.repository.unique_name,
1009+ target_branch.repository.unique_name]
1010+ expected_data = {
1011+ 'person_name': reviewer.displayname,
1012+ 'repositories_to_check': repositories_to_check,
1013+ 'visible_repositories':
1014+ [self.source_branch.repository.unique_name]}
1015+ result_data = view.register_action.success(self._getFormValues(
1016+ target_branch, {
1017+ 'reviewer': reviewer,
1018+ 'needs_review': True,
1019+ }))
1020+ self.assertEqual(
1021+ '400 Repository Visibility',
1022+ view.request.response.getStatusString())
1023+ self.assertEqual(expected_data, simplejson.loads(result_data))
1024+
1025+ def test_register_ajax_request_with_validation_errors(self):
1026+ # Ajax submits where there is a validation error in the submitted data
1027+ # return the expected json response containing the error info.
1028+ owner = self.factory.makePerson()
1029+ target_branch = self._makeTargetBranch(
1030+ owner=owner, information_type=InformationType.USERDATA)
1031+ extra = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
1032+ with person_logged_in(owner):
1033+ request = LaunchpadTestRequest(
1034+ method='POST', principal=owner,
1035+ form={
1036+ 'field.actions.register': 'Propose Merge',
1037+ 'field.target_git_repository.target_git_repository':
1038+ target_branch.repository.unique_name,
1039+ 'field.target_git_path': target_branch.path,
1040+ },
1041+ **extra)
1042+ view = create_initialized_view(
1043+ target_branch,
1044+ name='+register-merge',
1045+ request=request)
1046+ self.assertEqual(
1047+ '400 Validation', view.request.response.getStatusString())
1048+ self.assertEqual(
1049+ {'error_summary': 'There is 1 error.',
1050+ 'errors': {
1051+ 'field.target_git_path':
1052+ ('The target repository and path together cannot be the '
1053+ 'same as the source repository and path.')},
1054+ 'form_wide_errors': []},
1055+ simplejson.loads(view.form_result))
1056+
1057+
1058 class TestBranchMergeProposalResubmitViewMixin:
1059 """Test BranchMergeProposalResubmitView."""
1060
1061
1062=== modified file 'lib/lp/code/templates/gitref-pending-merges.pt'
1063--- lib/lp/code/templates/gitref-pending-merges.pt 2015-04-28 16:39:15 +0000
1064+++ lib/lp/code/templates/gitref-pending-merges.pt 2015-04-30 12:30:35 +0000
1065@@ -38,6 +38,11 @@
1066 </div>
1067
1068 </div>
1069+ <div
1070+ tal:define="link context_menu/register_merge"
1071+ tal:condition="link/enabled"
1072+ tal:replace="structure link/render"
1073+ />
1074 </div>
1075
1076 </div>
1077
1078=== added file 'lib/lp/code/templates/gitref-register-merge.pt'
1079--- lib/lp/code/templates/gitref-register-merge.pt 1970-01-01 00:00:00 +0000
1080+++ lib/lp/code/templates/gitref-register-merge.pt 2015-04-30 12:30:35 +0000
1081@@ -0,0 +1,81 @@
1082+<html
1083+ xmlns="http://www.w3.org/1999/xhtml"
1084+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1085+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1086+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1087+ metal:use-macro="view/macro:page/main_only"
1088+ i18n:domain="launchpad">
1089+ <body>
1090+ <div metal:fill-slot="main">
1091+ <div metal:use-macro="context/@@launchpad_form/form">
1092+
1093+ <metal:formbody fill-slot="widgets">
1094+ <table class="form">
1095+
1096+ <tal:widget define="widget nocall:view/widgets/target_git_repository">
1097+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1098+ </tal:widget>
1099+
1100+ <tal:widget define="widget nocall:view/widgets/target_git_path">
1101+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1102+ </tal:widget>
1103+
1104+ <tal:widget define="widget nocall:view/widgets/comment">
1105+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1106+ </tal:widget>
1107+
1108+ <tal:widget define="widget nocall:view/widgets/reviewer">
1109+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1110+ </tal:widget>
1111+
1112+ <tal:widget define="widget nocall:view/widgets/review_type">
1113+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1114+ </tal:widget>
1115+
1116+ <td colspan="2">
1117+ <fieldset id="mergeproposal-extra-options"
1118+ class="collapsible">
1119+ <legend>Extra options</legend>
1120+ <div class="hide-on-load"><!-- hidden by default -->
1121+ <table class="extra-options">
1122+ <tal:widget define="widget nocall:view/widgets/commit_message">
1123+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1124+ </tal:widget>
1125+
1126+ <tal:widget define="widget nocall:view/widgets/needs_review">
1127+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1128+ </tal:widget>
1129+
1130+ <tal:widget define="widget nocall:view/widgets/prerequisite_git_repository">
1131+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1132+ </tal:widget>
1133+
1134+ <tal:widget define="widget nocall:view/widgets/prerequisite_git_path">
1135+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1136+ </tal:widget>
1137+ </table>
1138+ </div>
1139+ </fieldset>
1140+ </td>
1141+
1142+ </table>
1143+ </metal:formbody>
1144+ </div>
1145+
1146+<!-- XXX cjwatson 2015-04-18: Hook this up once the JS handles repositories.
1147+ <tal:script>
1148+ <script type="text/javascript">
1149+ LPJS.use('lp.code.branchmergeproposal.nominate', function(Y) {
1150+ Y.on('domready',
1151+ function(e) {
1152+ Y.lp.code.branchmergeproposal.nominate.setup();
1153+ },
1154+ window);
1155+ });
1156+ </script>
1157+ </tal:script>
1158+-->
1159+
1160+ </div>
1161+ </body>
1162+</html>