← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-mp-basic-browser into lp:launchpad with lp:~cjwatson/launchpad/storm-vocabulary-base as a prerequisite.

Commit message:
Add Git support to most of the merge proposal views.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1445017 in Launchpad itself: "Support for Launchpad Git merge proposals "
  https://bugs.launchpad.net/launchpad/+bug/1445017

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-mp-basic-browser/+merge/257674

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-mp-basic-browser into lp:launchpad.
=== modified file 'lib/lp/code/browser/branchmergeproposal.py'
--- lib/lp/code/browser/branchmergeproposal.py	2015-04-22 16:41:41 +0000
+++ lib/lp/code/browser/branchmergeproposal.py	2015-04-28 16:48:46 +0000
@@ -86,6 +86,7 @@
     InvalidBranchMergeProposal,
     WrongBranchMergeProposal,
     )
+from lp.code.interfaces.branch import IBranch
 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
 from lp.code.interfaces.codereviewcomment import ICodeReviewComment
 from lp.code.interfaces.codereviewinlinecomment import (
@@ -355,7 +356,7 @@
     @property
     def codebrowse_url(self):
         """Return the link to codebrowse for this branch."""
-        return self.context.source_branch.getCodebrowseUrl()
+        return self.context.merge_source.getCodebrowseUrl()
 
 
 class BranchMergeProposalRevisionIdMixin:
@@ -373,6 +374,9 @@
             return None
         # If the source branch is REMOTE, then there won't be any ids.
         source_branch = self.context.source_branch
+        if source_branch is None:
+            # Git doesn't have revision numbers.  Just use the ids.
+            return revision_id
         if source_branch.branch_type == BranchType.REMOTE:
             return revision_id
         else:
@@ -586,12 +590,18 @@
     def initialize(self):
         super(BranchMergeProposalView, self).initialize()
         cache = IJSONRequestCache(self.request)
-        cache.objects.update({
-            'branch_diff_link':
-                'https://%s/+loggerhead/%s/diff/' % (
-                    config.launchpad.code_domain,
-                    self.context.source_branch.unique_name),
-            })
+        if IBranch.providedBy(self.context.merge_source):
+            cache.objects.update({
+                'branch_diff_link':
+                    'https://%s/+loggerhead/%s/diff/' % (
+                        config.launchpad.code_domain,
+                        self.context.source_branch.unique_name),
+                })
+        else:
+            cache.objects.update({
+                'repository_diff_link':
+                    '%s/diff/' % self.context.merge_source.getCodebrowseUrl(),
+                })
         if getFeatureFlag("longpoll.merge_proposals.enabled"):
             cache.objects['merge_proposal_event_key'] = subscribe(
                 self.context).event_key
@@ -618,7 +628,9 @@
         # Sort the comments by date order.
         merge_proposal = self.context
         groups = list(merge_proposal.getRevisionsSinceReviewStart())
-        source = DecoratedBranch(merge_proposal.source_branch)
+        source = merge_proposal.merge_source
+        if IBranch.providedBy(source):
+            source = DecoratedBranch(source)
         comments = []
         if getFeatureFlag('code.incremental_diffs.enabled'):
             ranges = [
@@ -685,6 +697,8 @@
 
     @property
     def pending_diff(self):
+        if self.context.source_branch is None:
+            return False
         return (
             self.context.next_preview_diff_job is not None or
             self.context.source_branch.pending_writes)
@@ -705,6 +719,10 @@
 
     @property
     def spec_links(self):
+        if self.context.source_branch is None:
+            # XXX cjwatson 2015-04-16: Implement once Git refs have linked
+            # blueprints.
+            return []
         return list(
             self.context.source_branch.getSpecificationLinks(self.user))
 
@@ -748,12 +766,16 @@
     @property
     def status_config(self):
         """The config to configure the ChoiceSource JS widget."""
+        if IBranch.providedBy(self.context.merge_source):
+            source_revid = self.context.merge_source.last_scanned_id
+        else:
+            source_revid = self.context.merge_source.commit_sha1
         return simplejson.dumps({
             'status_widget_items': vocabulary_to_choice_edit_items(
                 self._createStatusVocabulary(),
                 css_class_prefix='mergestatus'),
             'status_value': self.context.queue_status.title,
-            'source_revid': self.context.source_branch.last_scanned_id,
+            'source_revid': source_revid,
             'user_can_edit_status': check_permission(
                 'launchpad.Edit', self.context),
             })
@@ -965,19 +987,35 @@
     schema = ResubmitSchema
     for_input = True
     page_title = label = "Resubmit proposal to merge"
-    field_names = [
-        'source_branch',
-        'target_branch',
-        'prerequisite_branch',
-        'description',
-        'break_link',
-        ]
 
     def initialize(self):
         self.cancel_url = canonical_url(self.context)
         super(BranchMergeProposalResubmitView, self).initialize()
 
     @property
+    def field_names(self):
+        if IBranch.providedBy(self.context.merge_source):
+            field_names = [
+                'source_branch',
+                'target_branch',
+                'prerequisite_branch',
+                ]
+        else:
+            field_names = [
+                'source_git_repository',
+                'source_git_path',
+                'target_git_repository',
+                'target_git_path',
+                'prerequisite_git_repository',
+                'prerequisite_git_path',
+                ]
+        field_names.extend([
+            'description',
+            'break_link',
+            ])
+        return field_names
+
+    @property
     def initial_values(self):
         UNSET = object()
         items = ((key, getattr(self.context, key, UNSET)) for key in
@@ -988,10 +1026,24 @@
     def resubmit_action(self, action, data):
         """Resubmit this proposal."""
         try:
+            if IBranch.providedBy(self.context.merge_source):
+                merge_source = data['source_branch']
+                merge_target = data['target_branch']
+                merge_prerequisite = data['prerequisite_branch']
+            else:
+                merge_source = data['source_git_repository'].getRefByPath(
+                    data['source_git_path'])
+                merge_target = data['target_git_repository'].getRefByPath(
+                    data['target_git_path'])
+                if data['prerequisite_git_repository']:
+                    merge_prerequisite = (
+                        data['prerequisite_git_repository'].getRefByPath(
+                            data['prerequisite_git_path']))
+                else:
+                    merge_prerequisite = None
             proposal = self.context.resubmit(
-                self.user, data['source_branch'], data['target_branch'],
-                data['prerequisite_branch'], data['description'],
-                data['break_link'])
+                self.user, merge_source, merge_target, merge_prerequisite,
+                data['description'], data['break_link'])
         except BranchMergeProposalExists as e:
             message = structured(
                 'Cannot resubmit because <a href="%(url)s">a similar merge'
@@ -1072,18 +1124,31 @@
     """The view to mark a merge proposal as merged."""
     schema = IBranchMergeProposal
     page_title = label = "Edit branch merge proposal"
-    field_names = ["merged_revno"]
     for_input = True
 
     @property
+    def field_names(self):
+        if IBranch.providedBy(self.context.merge_target):
+            return ["merged_revno"]
+        else:
+            return ["merged_revision_id"]
+
+    @property
     def initial_values(self):
         # Default to the tip of the target branch, on the assumption that the
         # source branch has just been merged into it.
-        if self.context.merged_revno is not None:
-            revno = self.context.merged_revno
+        if IBranch.providedBy(self.context.merge_target):
+            if self.context.merged_revno is not None:
+                revno = self.context.merged_revno
+            else:
+                revno = self.context.merge_target.revision_count
+            return {'merged_revno': revno}
         else:
-            revno = self.context.target_branch.revision_count
-        return {'merged_revno': revno}
+            if self.context.merged_revision_id is not None:
+                revision_id = self.context.merged_revision_id
+            else:
+                revision_id = self.context.merge_target.commit_sha1
+            return {'merged_revision_id': revision_id}
 
     @property
     def next_url(self):
@@ -1095,13 +1160,16 @@
     @notify
     def mark_merged_action(self, action, data):
         """Update the whiteboard and go back to the source branch."""
-        revno = data['merged_revno']
+        if IBranch.providedBy(self.context.merge_target):
+            kwargs = {'merged_revno': data['merged_revno']}
+        else:
+            kwargs = {'merged_revision_id': data['merged_revision_id']}
         if self.context.queue_status == BranchMergeProposalStatus.MERGED:
-            self.context.markAsMerged(merged_revno=revno)
+            self.context.markAsMerged(**kwargs)
             self.request.response.addNotification(
                 'The proposal\'s merged revision has been updated.')
         else:
-            self.context.markAsMerged(revno, merge_reporter=self.user)
+            self.context.markAsMerged(merge_reporter=self.user, **kwargs)
             self.request.response.addNotification(
                 'The proposal has now been marked as merged.')
 

=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2015-04-22 16:11:40 +0000
+++ lib/lp/code/browser/configure.zcml	2015-04-28 16:48:46 +0000
@@ -824,6 +824,9 @@
         <browser:page
             name="++ref-commits"
             template="../templates/gitref-commits.pt"/>
+        <browser:page
+            name="++ref-pending-merges"
+            template="../templates/gitref-pending-merges.pt"/>
     </browser:pages>
     <browser:page
         for="lp.code.interfaces.gitref.IGitRef"

=== modified file 'lib/lp/code/browser/gitref.py'
--- lib/lp/code/browser/gitref.py	2015-04-22 16:11:40 +0000
+++ lib/lp/code/browser/gitref.py	2015-04-28 16:48:46 +0000
@@ -10,12 +10,19 @@
     'GitRefView',
     ]
 
+from lp.code.browser.branchmergeproposal import (
+    latest_proposals_for_each_branch,
+    )
 from lp.code.interfaces.gitref import IGitRef
+from lp.code.model.gitrepository import GitRepository
+from lp.services.database.bulk import load_related
+from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     LaunchpadView,
     Navigation,
     stepthrough,
     )
+from lp.services.webapp.authorization import check_permission
 
 
 class GitRefNavigation(Navigation):
@@ -49,3 +56,57 @@
             "author_date": self.context.author_date,
             "commit_message": self.context.commit_message,
             }
+
+    @property
+    def show_merge_links(self):
+        """Return whether or not merge proposal links should be shown.
+
+        Merge proposal links should not be shown if there is only one
+        reference in the entire target.
+        """
+        if not self.context.namespace.supports_merge_proposals:
+            return False
+        repositories = self.context.namespace.collection.getRepositories()
+        if repositories.count() > 1:
+            return True
+        repository = repositories.one()
+        if repository is None:
+            return False
+        return repository.refs.count() > 1
+
+    @cachedproperty
+    def landing_targets(self):
+        """Return a filtered list of landing targets."""
+        return latest_proposals_for_each_branch(self.context.landing_targets)
+
+    @cachedproperty
+    def landing_candidates(self):
+        """Return a decorated list of landing candidates."""
+        candidates = list(self.context.landing_candidates)
+        load_related(
+            GitRepository, candidates,
+            ["source_git_repositoryID", "prerequisite_git_repositoryID"])
+        return [proposal for proposal in candidates
+                if check_permission("launchpad.View", proposal)]
+
+    def _getBranchCountText(self, count):
+        """Help to show user friendly text."""
+        if count == 0:
+            return 'No branches'
+        elif count == 1:
+            return '1 branch'
+        else:
+            return '%s branches' % count
+
+    @cachedproperty
+    def landing_candidate_count_text(self):
+        return self._getBranchCountText(len(self.landing_candidates))
+
+    @cachedproperty
+    def dependent_landings(self):
+        return [proposal for proposal in self.context.dependent_landings
+                if check_permission("launchpad.View", proposal)]
+
+    @cachedproperty
+    def dependent_landing_count_text(self):
+        return self._getBranchCountText(len(self.dependent_landings))

=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
--- lib/lp/code/browser/tests/test_branchmergeproposal.py	2015-04-19 12:56:32 +0000
+++ lib/lp/code/browser/tests/test_branchmergeproposal.py	2015-04-28 16:48:46 +0000
@@ -46,6 +46,7 @@
     BranchMergeProposalStatus,
     CodeReviewVote,
     )
+from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
 from lp.code.model.diff import PreviewDiff
 from lp.code.tests.helpers import (
     add_revision_to_branch,
@@ -55,6 +56,7 @@
     PersonVisibility,
     TeamMembershipPolicy,
     )
+from lp.services.features.testing import FeatureFixture
 from lp.services.librarian.interfaces.client import LibrarianServerError
 from lp.services.messages.model.message import MessageSet
 from lp.services.webapp import canonical_url
@@ -107,8 +109,8 @@
         self.assertTrue(d.can_change_review)
 
 
-class TestBranchMergeProposalMergedView(TestCaseWithFactory):
-    """Tests for `BranchMergeProposalMergedView`."""
+class TestBranchMergeProposalMergedViewBzr(TestCaseWithFactory):
+    """Tests for `BranchMergeProposalMergedView` for Bazaar."""
 
     layer = DatabaseFunctionalLayer
 
@@ -129,6 +131,30 @@
             view.initial_values)
 
 
+class TestBranchMergeProposalMergedViewGit(TestCaseWithFactory):
+    """Tests for `BranchMergeProposalMergedView` for Git."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        # Use an admin so we don't have to worry about launchpad.Edit
+        # permissions on the merge proposals for adding comments, or
+        # nominating reviewers.
+        TestCaseWithFactory.setUp(self, user="admin@xxxxxxxxxxxxx")
+        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+        self.bmp = self.factory.makeBranchMergeProposalForGit()
+
+    def test_initial_values(self):
+        # The default merged_revision_id is the head commit SHA-1 of the
+        # target ref.
+        view = BranchMergeProposalMergedView(self.bmp, LaunchpadTestRequest())
+        removeSecurityProxy(self.bmp.source_git_ref).commit_sha1 = "0" * 40
+        removeSecurityProxy(self.bmp.target_git_ref).commit_sha1 = "1" * 40
+        self.assertEqual(
+            {'merged_revision_id': self.bmp.target_git_ref.commit_sha1},
+            view.initial_values)
+
+
 class TestBranchMergeProposalAddVoteView(TestCaseWithFactory):
     """Test the AddVote view."""
 
@@ -661,14 +687,14 @@
         self.assertEqual(BrowserNotificationLevel.INFO, notification.level)
 
 
-class TestBranchMergeProposalResubmitView(TestCaseWithFactory):
+class TestBranchMergeProposalResubmitViewMixin:
     """Test BranchMergeProposalResubmitView."""
 
     layer = DatabaseFunctionalLayer
 
     def createView(self):
         """Create the required view."""
-        context = self.factory.makeBranchMergeProposal()
+        context = self._makeBranchMergeProposal()
         self.useContext(person_logged_in(context.registrant))
         view = BranchMergeProposalResubmitView(
             context, LaunchpadTestRequest())
@@ -679,36 +705,34 @@
         """resubmit_action resubmits the proposal."""
         view = self.createView()
         context = view.context
-        new_proposal = view.resubmit_action.success(
-            {'source_branch': context.source_branch,
-             'target_branch': context.target_branch,
-             'prerequisite_branch': context.prerequisite_branch,
-             'description': None,
-             'break_link': False,
-            })
+        new_proposal = view.resubmit_action.success(self._getFormValues(
+            context.merge_source, context.merge_target,
+            context.merge_prerequisite, {
+                'description': None,
+                'break_link': False,
+                }))
         self.assertEqual(new_proposal.supersedes, context)
-        self.assertEqual(new_proposal.source_branch, context.source_branch)
-        self.assertEqual(new_proposal.target_branch, context.target_branch)
+        self.assertEqual(new_proposal.merge_source, context.merge_source)
+        self.assertEqual(new_proposal.merge_target, context.merge_target)
         self.assertEqual(
-            new_proposal.prerequisite_branch, context.prerequisite_branch)
+            new_proposal.merge_prerequisite, context.merge_prerequisite)
 
     def test_resubmit_action_change_branches(self):
         """Changing the branches changes the branches in the new proposal."""
         view = self.createView()
-        target = view.context.source_branch.target
-        new_source = self.factory.makeBranchTargetBranch(target)
-        new_target = self.factory.makeBranchTargetBranch(target)
-        new_prerequisite = self.factory.makeBranchTargetBranch(target)
-        new_proposal = view.resubmit_action.success(
-            {'source_branch': new_source, 'target_branch': new_target,
-             'prerequisite_branch': new_prerequisite,
-             'description': 'description',
-             'break_link': False,
-             })
+        new_source = self._makeBranchWithSameTarget(view.context.merge_source)
+        new_target = self._makeBranchWithSameTarget(view.context.merge_source)
+        new_prerequisite = self._makeBranchWithSameTarget(
+            view.context.merge_source)
+        new_proposal = view.resubmit_action.success(self._getFormValues(
+            new_source, new_target, new_prerequisite, {
+                'description': 'description',
+                'break_link': False,
+                }))
         self.assertEqual(new_proposal.supersedes, view.context)
-        self.assertEqual(new_proposal.source_branch, new_source)
-        self.assertEqual(new_proposal.target_branch, new_target)
-        self.assertEqual(new_proposal.prerequisite_branch, new_prerequisite)
+        self.assertEqual(new_proposal.merge_source, new_source)
+        self.assertEqual(new_proposal.merge_target, new_target)
+        self.assertEqual(new_proposal.merge_prerequisite, new_prerequisite)
 
     def test_resubmit_action_break_link(self):
         """Enabling break_link prevents linking the old and new proposals."""
@@ -716,24 +740,22 @@
         new_proposal = self.resubmitDefault(view, break_link=True)
         self.assertIs(None, new_proposal.supersedes)
 
-    @staticmethod
-    def resubmitDefault(view, break_link=False, prerequisite_branch=None):
+    @classmethod
+    def resubmitDefault(cls, view, break_link=False, merge_prerequisite=None):
         context = view.context
-        if prerequisite_branch is None:
-            prerequisite_branch = context.prerequisite_branch
-        return view.resubmit_action.success(
-            {'source_branch': context.source_branch,
-             'target_branch': context.target_branch,
-             'prerequisite_branch': prerequisite_branch,
-             'description': None,
-             'break_link': break_link,
-            })
+        if merge_prerequisite is None:
+            merge_prerequisite = context.merge_prerequisite
+        return view.resubmit_action.success(cls._getFormValues(
+            context.merge_source, context.merge_target, merge_prerequisite, {
+                'description': None,
+                'break_link': break_link,
+                }))
 
     def test_resubmit_existing(self):
         """Resubmitting a proposal when another is active is a user error."""
         view = self.createView()
         first_bmp = view.context
-        with person_logged_in(first_bmp.target_branch.owner):
+        with person_logged_in(first_bmp.merge_target.owner):
             first_bmp.resubmit(first_bmp.registrant)
         self.resubmitDefault(view)
         (notification,) = view.request.response.notifications
@@ -742,19 +764,86 @@
             ' <a href=.*>a similar merge proposal</a> is already active.'))
         self.assertEqual(BrowserNotificationLevel.ERROR, notification.level)
 
+
+class TestBranchMergeProposalResubmitViewBzr(
+    TestBranchMergeProposalResubmitViewMixin, TestCaseWithFactory):
+    """Test BranchMergeProposalResubmitView for Bazaar."""
+
+    def _makeBranchMergeProposal(self):
+        return self.factory.makeBranchMergeProposal()
+
+    @staticmethod
+    def _getFormValues(source_branch, target_branch, prerequisite_branch,
+                       extras):
+        values = {
+            'source_branch': source_branch,
+            'target_branch': target_branch,
+            'prerequisite_branch': prerequisite_branch,
+            }
+        values.update(extras)
+        return values
+
+    def _makeBranchWithSameTarget(self, branch):
+        return self.factory.makeBranchTargetBranch(branch.target)
+
     def test_resubmit_same_target_prerequisite(self):
         """User error if same branch is target and prerequisite."""
         view = self.createView()
         first_bmp = view.context
-        self.resubmitDefault(
-            view, prerequisite_branch=first_bmp.target_branch)
+        self.resubmitDefault(view, merge_prerequisite=first_bmp.merge_target)
         self.assertEqual(
             view.errors,
             ['Target and prerequisite branches must be different.'])
 
 
-class TestResubmitBrowser(BrowserTestCase):
-    """Browser tests for resubmitting branch merge proposals."""
+class TestBranchMergeProposalResubmitViewGit(
+    TestBranchMergeProposalResubmitViewMixin, TestCaseWithFactory):
+    """Test BranchMergeProposalResubmitView for Git."""
+
+    def setUp(self):
+        super(TestBranchMergeProposalResubmitViewGit, self).setUp()
+        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+
+    def _makeBranchMergeProposal(self):
+        return self.factory.makeBranchMergeProposalForGit()
+
+    @staticmethod
+    def _getFormValues(source_branch, target_branch, prerequisite_branch,
+                       extras):
+        values = {
+            'source_git_repository': source_branch.repository,
+            'source_git_path': source_branch.path,
+            'target_git_repository': target_branch.repository,
+            'target_git_path': target_branch.path,
+            }
+        if prerequisite_branch is not None:
+            values.update({
+                'prerequisite_git_repository': prerequisite_branch.repository,
+                'prerequisite_git_path': prerequisite_branch.path,
+                })
+        else:
+            values.update({
+                'prerequisite_git_repository': None,
+                'prerequisite_git_path': '',
+                })
+        values.update(extras)
+        return values
+
+    def _makeBranchWithSameTarget(self, branch):
+        return self.factory.makeGitRefs(target=branch.target)[0]
+
+    def test_resubmit_same_target_prerequisite(self):
+        """User error if same branch is target and prerequisite."""
+        view = self.createView()
+        first_bmp = view.context
+        self.resubmitDefault(view, merge_prerequisite=first_bmp.merge_target)
+        self.assertEqual(
+            view.errors,
+            ['Target and prerequisite references must be different.'])
+
+
+class TestResubmitBrowserBzr(BrowserTestCase):
+    """Browser tests for resubmitting branch merge proposals for Bazaar."""
 
     layer = DatabaseFunctionalLayer
 
@@ -781,6 +870,41 @@
             self.assertEqual('flibble', bmp.superseded_by.description)
 
 
+class TestResubmitBrowserGit(BrowserTestCase):
+    """Browser tests for resubmitting branch merge proposals for Git."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestResubmitBrowserGit, self).setUp()
+        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+
+    def test_resubmit_text(self):
+        """The text of the resubmit page is as expected."""
+        bmp = self.factory.makeBranchMergeProposalForGit(registrant=self.user)
+        text = self.getMainText(bmp, '+resubmit')
+        expected = (
+            'Resubmit proposal to merge.*'
+            'Source Git Repository:.*'
+            'Source Git branch path:.*'
+            'Target Git Repository:.*'
+            'Target Git branch path:.*'
+            'Prerequisite Git Repository:.*'
+            'Prerequisite Git branch path:.*'
+            'Description.*'
+            'Start afresh.*')
+        self.assertTextMatchesExpressionIgnoreWhitespace(expected, text)
+
+    def test_resubmit_controls(self):
+        """Proposals can be resubmitted using the browser."""
+        bmp = self.factory.makeBranchMergeProposalForGit(registrant=self.user)
+        browser = self.getViewBrowser(bmp, '+resubmit')
+        browser.getControl('Description').value = 'flibble'
+        browser.getControl('Resubmit').click()
+        with person_logged_in(self.user):
+            self.assertEqual('flibble', bmp.superseded_by.description)
+
+
 class TestBranchMergeProposalView(TestCaseWithFactory):
 
     layer = LaunchpadFunctionalLayer
@@ -1248,17 +1372,17 @@
         self.assertEqual(mp_url, browser.url)
 
 
-class TestLatestProposalsForEachBranch(TestCaseWithFactory):
+class TestLatestProposalsForEachBranchMixin:
     """Confirm that the latest branch is returned."""
 
     layer = DatabaseFunctionalLayer
 
     def test_newest_first(self):
         # If each proposal targets a different branch, each will be returned.
-        bmp1 = self.factory.makeBranchMergeProposal(
+        bmp1 = self._makeBranchMergeProposal(
             date_created=(
                 datetime(year=2008, month=9, day=10, tzinfo=pytz.UTC)))
-        bmp2 = self.factory.makeBranchMergeProposal(
+        bmp2 = self._makeBranchMergeProposal(
             date_created=(
                 datetime(year=2008, month=10, day=10, tzinfo=pytz.UTC)))
         self.assertEqual(
@@ -1266,27 +1390,57 @@
 
     def test_visible_filtered_out(self):
         # If the proposal is not visible to the user, they are not returned.
-        bmp1 = self.factory.makeBranchMergeProposal(
+        bmp1 = self._makeBranchMergeProposal(
             date_created=(
                 datetime(year=2008, month=9, day=10, tzinfo=pytz.UTC)))
-        bmp2 = self.factory.makeBranchMergeProposal(
+        bmp2 = self._makeBranchMergeProposal(
             date_created=(
                 datetime(year=2008, month=10, day=10, tzinfo=pytz.UTC)))
-        removeSecurityProxy(bmp2.source_branch).transitionToInformationType(
-            InformationType.USERDATA, bmp2.source_branch.owner,
-            verify_policy=False)
+        self._setBranchInvisible(bmp2.merge_source)
         self.assertEqual(
             [bmp1], latest_proposals_for_each_branch([bmp1, bmp2]))
 
     def test_same_target(self):
         # If the proposals target the same branch, then the most recent is
         # returned.
-        bmp1 = self.factory.makeBranchMergeProposal(
+        bmp1 = self._makeBranchMergeProposal(
             date_created=(
                 datetime(year=2008, month=9, day=10, tzinfo=pytz.UTC)))
-        bmp2 = self.factory.makeBranchMergeProposal(
-            target_branch=bmp1.target_branch,
+        bmp2 = self._makeBranchMergeProposal(
+            merge_target=bmp1.merge_target,
             date_created=(
                 datetime(year=2008, month=10, day=10, tzinfo=pytz.UTC)))
         self.assertEqual(
             [bmp2], latest_proposals_for_each_branch([bmp1, bmp2]))
+
+
+class TestLatestProposalsForEachBranchBzr(
+    TestLatestProposalsForEachBranchMixin, TestCaseWithFactory):
+    """Confirm that the latest branch is returned for Bazaar."""
+
+    def _makeBranchMergeProposal(self, merge_target=None, **kwargs):
+        return self.factory.makeBranchMergeProposal(
+            target_branch=merge_target, **kwargs)
+
+    @staticmethod
+    def _setBranchInvisible(branch):
+        removeSecurityProxy(branch).transitionToInformationType(
+            InformationType.USERDATA, branch.owner, verify_policy=False)
+
+
+class TestLatestProposalsForEachBranchGit(
+    TestLatestProposalsForEachBranchMixin, TestCaseWithFactory):
+    """Confirm that the latest branch is returned for Bazaar."""
+
+    def setUp(self):
+        super(TestLatestProposalsForEachBranchGit, self).setUp()
+        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+
+    def _makeBranchMergeProposal(self, merge_target=None, **kwargs):
+        return self.factory.makeBranchMergeProposalForGit(
+            target_ref=merge_target, **kwargs)
+
+    @staticmethod
+    def _setBranchInvisible(branch):
+        removeSecurityProxy(branch.repository).transitionToInformationType(
+            InformationType.USERDATA, branch.owner, verify_policy=False)

=== modified file 'lib/lp/code/errors.py'
--- lib/lp/code/errors.py	2015-04-19 12:56:32 +0000
+++ lib/lp/code/errors.py	2015-04-28 16:48:46 +0000
@@ -231,11 +231,18 @@
     """Raised if there is already a matching BranchMergeProposal."""
 
     def __init__(self, existing_proposal):
+        # Circular import.
+        from lp.code.interfaces.branch import IBranch
+        # display_name is the newer style, but IBranch uses the older style.
+        if IBranch.providedBy(existing_proposal.merge_source):
+            display_name = "displayname"
+        else:
+            display_name = "display_name"
         super(BranchMergeProposalExists, self).__init__(
                 'There is already a branch merge proposal registered for '
                 'branch %s to land on %s that is still active.' %
-                (existing_proposal.source_branch.displayname,
-                 existing_proposal.target_branch.displayname))
+                (getattr(existing_proposal.merge_source, display_name),
+                 getattr(existing_proposal.merge_target, display_name)))
         self.existing_proposal = existing_proposal
 
 

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2015-04-22 16:11:40 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2015-04-28 16:48:46 +0000
@@ -49,6 +49,7 @@
     Choice,
     Datetime,
     Int,
+    List,
     Text,
     TextLine,
     )
@@ -607,6 +608,39 @@
         :return: A collection of `IGitRepository` objects.
         """
 
+    @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        person=Reference(
+            title=_("The person whose repository visibility is being "
+                    "checked."),
+            schema=IPerson),
+        repository_names=List(value_type=Text(),
+            title=_('List of repository unique names'), required=True),
+    )
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getRepositoryVisibilityInfo(user, person, repository_names):
+        """Return the named repositories visible to both user and person.
+
+        Anonymous requesters don't get any information.
+
+        :param user: The user requesting the information. If the user is
+            None then we return an empty dict.
+        :param person: The person whose repository visibility we wish to
+            check.
+        :param repository_names: The unique names of the repositories to
+            check.
+
+        Return a dict with the following values:
+        person_name: the displayname of the person.
+        visible_repositories: a list of the unique names of the repositories
+        which the requester and specified person can both see.
+
+        This API call is provided for use by the client Javascript.  It is
+        not designed to efficiently scale to handle requests for large
+        numbers of repositories.
+        """
+
     @operation_parameters(
         target=Reference(
             title=_("Target"), required=True, schema=IHasGitRepositories))

=== modified file 'lib/lp/code/model/gitref.py'
--- lib/lp/code/model/gitref.py	2015-04-24 12:58:46 +0000
+++ lib/lp/code/model/gitref.py	2015-04-28 16:48:46 +0000
@@ -376,3 +376,6 @@
 
     def __ne__(self, other):
         return not self == other
+
+    def __hash__(self):
+        return hash(self.repository) ^ hash(self.path) ^ hash(self.commit_sha1)

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2015-04-22 16:42:57 +0000
+++ lib/lp/code/model/gitrepository.py	2015-04-28 16:48:46 +0000
@@ -38,6 +38,7 @@
 from storm.store import Store
 from zope.component import getUtility
 from zope.interface import implements
+from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import (
@@ -774,6 +775,27 @@
         collection = IGitCollection(target).visibleByUser(user)
         return collection.getRepositories(eager_load=True)
 
+    def getRepositoryVisibilityInfo(self, user, person, repository_names):
+        """See `IGitRepositorySet`."""
+        if user is None:
+            return dict()
+        lookup = getUtility(IGitLookup)
+        visible_repositories = []
+        for name in repository_names:
+            repository = lookup.getByUniqueName(name)
+            try:
+                if (repository is not None
+                        and repository.visibleByUser(user)
+                        and repository.visibleByUser(person)):
+                    visible_repositories.append(repository.unique_name)
+            except Unauthorized:
+                # We don't include repositories user cannot see.
+                pass
+        return {
+            'person_name': person.displayname,
+            'visible_repositories': visible_repositories,
+            }
+
     def getDefaultRepository(self, target):
         """See `IGitRepositorySet`."""
         clauses = [GitRepository.target_default == True]

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2015-04-21 23:05:48 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2015-04-28 16:48:46 +0000
@@ -1216,6 +1216,80 @@
             public_repositories + [private_repository],
             self.repository_set.getRepositories(other_person, project))
 
+    def test_getRepositoryVisibilityInfo_empty_repository_names(self):
+        # If repository_names is empty, getRepositoryVisibilityInfo returns
+        # an empty visible_repositories list.
+        person = self.factory.makePerson(name="fred")
+        info = self.repository_set.getRepositoryVisibilityInfo(
+            person, person, repository_names=[])
+        self.assertEqual("Fred", info["person_name"])
+        self.assertEqual([], info["visible_repositories"])
+
+    def test_getRepositoryVisibilityInfo(self):
+        person = self.factory.makePerson(name="fred")
+        owner = self.factory.makePerson()
+        visible_repository = self.factory.makeGitRepository()
+        invisible_repository = self.factory.makeGitRepository(
+            owner=owner, information_type=InformationType.USERDATA)
+        invisible_name = removeSecurityProxy(invisible_repository).unique_name
+        repositories = [visible_repository.unique_name, invisible_name]
+
+        with person_logged_in(owner):
+            info = self.repository_set.getRepositoryVisibilityInfo(
+                owner, person, repository_names=repositories)
+        self.assertEqual("Fred", info["person_name"])
+        self.assertEqual(
+            [visible_repository.unique_name], info["visible_repositories"])
+
+    def test_getRepositoryVisibilityInfo_unauthorised_user(self):
+        # If the user making the API request cannot see one of the
+        # repositories, that repository is not included in the results.
+        person = self.factory.makePerson(name="fred")
+        owner = self.factory.makePerson()
+        visible_repository = self.factory.makeGitRepository()
+        invisible_repository = self.factory.makeGitRepository(
+            owner=owner, information_type=InformationType.USERDATA)
+        invisible_name = removeSecurityProxy(invisible_repository).unique_name
+        repositories = [visible_repository.unique_name, invisible_name]
+
+        someone = self.factory.makePerson()
+        with person_logged_in(someone):
+            info = self.repository_set.getRepositoryVisibilityInfo(
+                someone, person, repository_names=repositories)
+        self.assertEqual("Fred", info["person_name"])
+        self.assertEqual(
+            [visible_repository.unique_name], info["visible_repositories"])
+
+    def test_getRepositoryVisibilityInfo_anonymous(self):
+        # Anonymous users are not allowed to see any repository visibility
+        # information, even if the repository they are querying about is
+        # public.
+        person = self.factory.makePerson(name="fred")
+        owner = self.factory.makePerson()
+        visible_repository = self.factory.makeGitRepository(owner=owner)
+        repositories = [visible_repository.unique_name]
+
+        with person_logged_in(owner):
+            info = self.repository_set.getRepositoryVisibilityInfo(
+                None, person, repository_names=repositories)
+        self.assertEqual({}, info)
+
+    def test_getRepositoryVisibilityInfo_invalid_repository_name(self):
+        # If an invalid repository name is specified, it is not included.
+        person = self.factory.makePerson(name="fred")
+        owner = self.factory.makePerson()
+        visible_repository = self.factory.makeGitRepository(owner=owner)
+        repositories = [
+            visible_repository.unique_name,
+            "invalid_repository_name"]
+
+        with person_logged_in(owner):
+            info = self.repository_set.getRepositoryVisibilityInfo(
+                owner, person, repository_names=repositories)
+        self.assertEqual("Fred", info["person_name"])
+        self.assertEqual(
+            [visible_repository.unique_name], info["visible_repositories"])
+
     def test_setDefaultRepository_refuses_person(self):
         # setDefaultRepository refuses if the target is a person.
         person = self.factory.makePerson()

=== modified file 'lib/lp/code/templates/branchmergeproposal-resubmit.pt'
--- lib/lp/code/templates/branchmergeproposal-resubmit.pt	2010-11-04 16:56:35 +0000
+++ lib/lp/code/templates/branchmergeproposal-resubmit.pt	2015-04-28 16:48:46 +0000
@@ -24,7 +24,7 @@
     </div>
   </div>
 
-  <div id="source-revisions">
+  <div id="source-revisions" tal:condition="context/source_branch">
     <tal:history-available condition="context/source_branch/revision_count"
                            define="branch context/source_branch;
                                    revisions view/unlanded_revisions">

=== modified file 'lib/lp/code/templates/gitref-index.pt'
--- lib/lp/code/templates/gitref-index.pt	2015-03-19 17:04:22 +0000
+++ lib/lp/code/templates/gitref-index.pt	2015-04-28 16:48:46 +0000
@@ -17,6 +17,13 @@
 <div metal:fill-slot="main">
 
   <div class="yui-g">
+    <div id="ref-relations" class="portlet">
+      <tal:ref-pending-merges
+           replace="structure context/@@++ref-pending-merges" />
+    </div>
+  </div>
+
+  <div class="yui-g">
     <div id="ref-info" class="portlet">
       <h2>Branch information</h2>
       <div class="two-column-list">

=== added file 'lib/lp/code/templates/gitref-pending-merges.pt'
--- lib/lp/code/templates/gitref-pending-merges.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitref-pending-merges.pt	2015-04-28 16:48:46 +0000
@@ -0,0 +1,43 @@
+<div
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  tal:define="
+      context_menu view/context/menu:context;
+      features request/features"
+  tal:condition="view/show_merge_links">
+
+  <h3>Branch merges</h3>
+  <div id="merge-links"
+       class="actions">
+    <div id="merge-summary">
+
+      <div id="landing-candidates"
+           tal:condition="view/landing_candidates">
+        <img src="/@@/merge-proposal-icon" />
+        <a href="+activereviews" tal:content="structure view/landing_candidate_count_text">
+          1 branch
+        </a>
+        proposed for merging into this one.
+
+      </div>
+
+      <div id="dependent-landings" tal:condition="view/dependent_landings">
+        <img src="/@@/merge-proposal-icon" />
+        <a href="+merges" tal:content="structure view/dependent_landing_count_text">
+          1 branch
+        </a>
+        dependent on this one.
+      </div>
+
+      <div id="landing-targets" tal:condition="view/landing_targets">
+        <tal:landing-candidates repeat="mergeproposal view/landing_targets">
+          <tal:merge-fragment
+              tal:replace="structure mergeproposal/@@+summary-fragment"/>
+        </tal:landing-candidates>
+      </div>
+
+    </div>
+  </div>
+
+</div>

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2015-04-22 16:11:40 +0000
+++ lib/lp/testing/factory.py	2015-04-28 16:48:46 +0000
@@ -1747,7 +1747,8 @@
                 u"type": GitObjectType.COMMIT,
                 }
             for path in paths}
-        return repository.createOrUpdateRefs(refs_info, get_objects=True)
+        return removeSecurityProxy(repository).createOrUpdateRefs(
+            refs_info, get_objects=True)
 
     def makeBug(self, target=None, owner=None, bug_watch_url=None,
                 information_type=None, date_closed=None, title=None,


References