← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/bmp-buglinktarget-ui into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/bmp-buglinktarget-ui into lp:launchpad with lp:~cjwatson/launchpad/bug-bmp-activity as a prerequisite.

Commit message:
Add UI and export webservice API for linking merge proposals to bugs.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1492926 in Launchpad itself: "Git merge proposals can't be linked to bugs"
  https://bugs.launchpad.net/launchpad/+bug/1492926

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/bmp-buglinktarget-ui/+merge/298368

Add UI and export webservice API for linking merge proposals to bugs.

Bugs display links to MPs where they exist; MPs have their "Related bugs and blueprints" summary section split into "Related bugs" and "Related blueprints" so that the former can more reasonably have a "Link a bug report" option.  There's currently no way to add a link from the bug side in the UI, although that could be done later.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/bmp-buglinktarget-ui into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2016-06-25 09:54:49 +0000
+++ lib/lp/_schema_circular_imports.py	2016-06-25 09:54:49 +0000
@@ -283,6 +283,8 @@
     IBranchMergeProposal, 'votes', ICodeReviewVoteReference)
 patch_collection_return_type(
     IBranchMergeProposal, 'getRelatedBugTasks', IBugTask)
+patch_plain_parameter_type(IBranchMergeProposal, 'linkBug', 'bug', IBug)
+patch_plain_parameter_type(IBranchMergeProposal, 'unlinkBug', 'bug', IBug)
 
 patch_collection_return_type(IHasBranches, 'getBranches', IBranch)
 patch_collection_return_type(
@@ -682,8 +684,12 @@
 patch_plain_parameter_type(
     IBug, 'getNominations', 'target', IBugTarget)
 patch_collection_property(IBug, 'linked_merge_proposals', IBranchMergeProposal)
+patch_plain_parameter_type(
+    IBug, 'linkMergeProposal', 'merge_proposal', IBranchMergeProposal)
 patch_choice_parameter_type(
     IBug, 'subscribe', 'level', BugNotificationLevel)
+patch_plain_parameter_type(
+    IBug, 'unlinkMergeProposal', 'merge_proposal', IBranchMergeProposal)
 
 
 # IFrontPageBugAddForm

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2016-01-26 15:47:37 +0000
+++ lib/lp/bugs/browser/bugtask.py	2016-06-25 09:54:49 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """IBugTask-related browser views."""
@@ -812,6 +812,12 @@
                 eager_load=True))
         return linked_branches
 
+    @cachedproperty
+    def linked_merge_proposals(self):
+        """Filter out the links to non-visible private MPs."""
+        return list(self.context.bug.getVisibleLinkedMergeProposals(
+            self.user, eager_load=True))
+
     @property
     def days_to_expiration(self):
         """Return the number of days before the bug is expired, or None."""

=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py	2016-06-25 09:54:49 +0000
+++ lib/lp/bugs/interfaces/bug.py	2016-06-25 09:54:49 +0000
@@ -817,6 +817,27 @@
         """
 
     @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        # Really IBranchMergeProposal, patched in _schema_circular_imports.py.
+        merge_proposal=Reference(
+            Interface, title=_('Merge proposal'), required=True))
+    @export_write_operation()
+    @operation_for_version('devel')
+    def linkMergeProposal(merge_proposal, user, check_permissions=True):
+        """Ensure that this MP is linked to this bug."""
+
+    @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        # Really IBranchMergeProposal, patched in _schema_circular_imports.py.
+        merge_proposal=Reference(
+            Interface, title=_('Merge proposal'), required=True))
+    @export_write_operation()
+    @operation_for_version('devel')
+    def unlinkMergeProposal(merge_proposal, user, check_permissions=True):
+        """Ensure that any links between this bug and the given MP are removed.
+        """
+
+    @call_with(user=REQUEST_USER)
     @operation_parameters(cve=Reference(ICve, title=_('CVE'), required=True))
     @export_write_operation()
     def linkCVE(cve, user, check_permissions=True):

=== modified file 'lib/lp/bugs/templates/bugtask-index.pt'
--- lib/lp/bugs/templates/bugtask-index.pt	2014-11-29 06:11:18 +0000
+++ lib/lp/bugs/templates/bugtask-index.pt	2016-06-25 09:54:49 +0000
@@ -206,8 +206,9 @@
         <div id="bug-branches-container"
             style="float: left">
           <tal:branches
-              define="linked_branches view/linked_branches"
-              condition="linked_branches">
+              define="linked_branches view/linked_branches;
+                      linked_merge_proposals view/linked_merge_proposals"
+              condition="python: linked_branches or linked_merge_proposals">
 
             <div id="bug-branches">
               <h2>Related branches</h2>
@@ -215,6 +216,12 @@
               <tal:bug-branches repeat="linked_branch linked_branches">
                 <tal:bug-branch replace="structure linked_branch/@@+bug-branch" />
               </tal:bug-branches>
+              <tal:bug-mps repeat="linked_merge_proposal linked_merge_proposals">
+                <tal:bug-mp define="proposal linked_merge_proposal;
+                                    bug context/bug">
+                  <metal:bug-mp use-macro="linked_merge_proposal/@@+bmp-macros/bug-summary" />
+                </tal:bug-mp>
+              </tal:bug-mps>
             </div>
           </tal:branches>
         </div><!-- bug-branch-container -->

=== modified file 'lib/lp/code/browser/branchmergeproposal.py'
--- lib/lp/code/browser/branchmergeproposal.py	2016-05-14 09:54:32 +0000
+++ lib/lp/code/browser/branchmergeproposal.py	2016-06-25 09:54:49 +0000
@@ -283,6 +283,10 @@
             BranchMergeProposalStatus.SUPERSEDED)
         return Link('+resubmit', text, enabled=enabled, icon='edit')
 
+    def link_bug(self):
+        text = 'Link a bug report'
+        return Link('+linkbug', text, icon='add')
+
 
 class BranchMergeProposalEditMenu(NavigationMenu,
                                   BranchMergeProposalMenuMixin):
@@ -308,6 +312,7 @@
         'set_commit_message',
         'set_description',
         'edit_status',
+        'link_bug',
         'merge',
         'request_review',
         'resubmit',
@@ -704,10 +709,9 @@
         return self.context.preview_diff
 
     @property
-    def has_bug_or_spec(self):
-        """Return whether or not the merge proposal has a linked bug or spec.
-        """
-        return self.linked_bugtasks or self.spec_links
+    def has_specs(self):
+        """Return whether the merge proposal has any linked specs."""
+        return bool(self.spec_links)
 
     @property
     def spec_links(self):
@@ -720,7 +724,7 @@
 
     @cachedproperty
     def linked_bugtasks(self):
-        """Return BugTasks linked to the source branch."""
+        """Return BugTasks linked to the MP or the source branch."""
         return self.context.getRelatedBugTasks(self.user)
 
     @property

=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2016-05-14 09:54:32 +0000
+++ lib/lp/code/browser/configure.zcml	2016-06-25 09:54:49 +0000
@@ -254,6 +254,18 @@
         permission="launchpad.Edit"
         template="../../app/templates/generic-edit.pt"/>
     <browser:page
+        name="+linkbug"
+        for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal"
+        class="lp.bugs.browser.buglinktarget.BugLinkView"
+        permission="launchpad.AnyPerson"
+        template="../../app/templates/generic-edit.pt"/>
+    <browser:page
+        name="+unlinkbug"
+        for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal"
+        class="lp.bugs.browser.buglinktarget.BugsUnlinkView"
+        permission="launchpad.AnyPerson"
+        template="../templates/branchmergeproposal-unlinkbugs.pt"/>
+    <browser:page
         name="+link-summary"
         for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal"
         class="lp.code.browser.branchmergeproposal.BranchMergeCandidateView"

=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
--- lib/lp/code/browser/tests/test_branchmergeproposal.py	2016-06-03 14:08:52 +0000
+++ lib/lp/code/browser/tests/test_branchmergeproposal.py	2016-06-25 09:54:49 +0000
@@ -1,7 +1,6 @@
 # Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-
 """Unit tests for BranchMergeProposals."""
 
 __metaclass__ = type
@@ -11,9 +10,11 @@
     timedelta,
     )
 from difflib import unified_diff
+import doctest
 import hashlib
 import re
 
+from fixtures import FakeLogger
 from lazr.lifecycle.event import ObjectModifiedEvent
 from lazr.restful.interfaces import IJSONRequestCache
 import pytz
@@ -23,6 +24,7 @@
     Tag,
     )
 from testtools.matchers import (
+    DocTestMatches,
     Equals,
     Is,
     MatchesDict,
@@ -81,6 +83,7 @@
 from lp.services.webapp.interfaces import BrowserNotificationLevel
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.testing import (
+    admin_logged_in,
     BrowserTestCase,
     EventRecorder,
     feature_flags,
@@ -102,6 +105,7 @@
 from lp.testing.pages import (
     extract_text,
     find_tag_by_id,
+    find_tags_by_class,
     first_tag_by_class,
     get_feedback_messages,
     )
@@ -1396,7 +1400,7 @@
         return PreviewDiff.create(
             self.bmp, preview_diff_bytes, u'a', u'b', None, u'')
 
-    def test_linked_bugs_excludes_mutual_bugs(self):
+    def test_linked_bugtasks_excludes_mutual_bugs(self):
         """List bugs that are linked to the source only."""
         bug = self.factory.makeBug()
         self.bmp.source_branch.linkBug(bug, self.bmp.registrant)
@@ -1404,7 +1408,7 @@
         view = create_initialized_view(self.bmp, '+index')
         self.assertEqual([], view.linked_bugtasks)
 
-    def test_linked_bugs_excludes_private_bugs(self):
+    def test_linked_bugtasks_excludes_private_bugs(self):
         """List bugs that are linked to the source only."""
         bug = self.factory.makeBug()
         person = self.factory.makePerson()
@@ -1416,6 +1420,15 @@
         view = create_initialized_view(self.bmp, '+index')
         self.assertEqual([bug.default_bugtask], view.linked_bugtasks)
 
+    def test_linked_bugtasks_includes_direct_links(self):
+        # linked_bugtasks includes bugs that are linked directly to the
+        # merge proposal, as is the case for Git-based MPs.
+        bug = self.factory.makeBug()
+        bmp = self.factory.makeBranchMergeProposalForGit(registrant=self.user)
+        bmp.linkBug(bug, bmp.registrant)
+        view = create_initialized_view(bmp, '+index')
+        self.assertEqual([bug.default_bugtask], view.linked_bugtasks)
+
     def makeRevisionGroups(self):
         review_date = datetime(2009, 9, 10, tzinfo=pytz.UTC)
         bmp = self.factory.makeBranchMergeProposal(
@@ -1985,6 +1998,134 @@
             InformationType.USERDATA, branch.owner, verify_policy=False)
 
 
+class TestBranchMergeProposalLinkBugViewMixin:
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestBranchMergeProposalLinkBugViewMixin, self).setUp()
+        self.bmp = self._makeBranchMergeProposal()
+
+    def test_anonymous(self):
+        # The "Link a bug report" link on BranchMergeProposal:+index is
+        # visible to all, but anonymous users will need to log in to use it.
+        self.useFixture(FakeLogger())
+        browser = self.getViewBrowser(
+            self.bmp, view_name="+index", no_login=True)
+        self.assertRaises(
+            Unauthorized, browser.getLink("Link a bug report").click)
+
+    def test_logged_in(self):
+        # Any logged-in user can use the "Link a bug report" link.
+        browser = self.getViewBrowser(self.bmp, view_name="+index")
+        browser.getLink("Link a bug report").click()
+        self.assertStartsWith(browser.title, "Link a bug report")
+
+    def assertBugLinks(self, bugtasks, browser):
+        expected_text = []
+        for bugtask in bugtasks:
+            expected_text.append(
+                "Bug #%d: %s\n%s\n%s" % (
+                    bugtask.bug.id, bugtask.bug.title,
+                    bugtask.importance.title, bugtask.status.title))
+        self.assertEqual(
+            expected_text,
+            [extract_text(tag) for tag in find_tags_by_class(
+                 browser.contents, "bug-mp-summary")])
+
+    def test_link(self):
+        # A user can enter a bug number to link from an MP to a bug.
+        bug = self.factory.makeBug()
+        bugtask = bug.default_bugtask
+        browser = self.getViewBrowser(self.bmp, view_name="+linkbug")
+        browser.getControl("Bug ID").value = str(bug.id)
+        browser.getControl("Link").click()
+        with person_logged_in(self.user):
+            self.assertBugLinks([bugtask], browser)
+
+    def test_same_link_twice(self):
+        # Attempting to link to the same bug twice only creates a single
+        # link.
+        bug = self.factory.makeBug()
+        bugtask = bug.default_bugtask
+        with person_logged_in(self.user):
+            self.bmp.linkBug(bug, self.user)
+        browser = self.getViewBrowser(self.bmp, view_name="+linkbug")
+        browser.getControl("Bug ID").value = str(bug.id)
+        browser.getControl("Link").click()
+        with person_logged_in(self.user):
+            self.assertBugLinks([bugtask], browser)
+
+
+class TestBranchMergeProposalLinkBugViewBzr(
+    TestBranchMergeProposalLinkBugViewMixin, BrowserTestCase):
+
+    def _makeBranchMergeProposal(self):
+        return self.factory.makeBranchMergeProposal()
+
+
+class TestBranchMergeProposalLinkBugViewGit(
+    TestBranchMergeProposalLinkBugViewMixin, GitHostingClientMixin,
+    BrowserTestCase):
+
+    def _makeBranchMergeProposal(self):
+        return self.factory.makeBranchMergeProposalForGit()
+
+    def assertMergeProposalLinks(self, mps, browser):
+        matchers = []
+        for mp in mps:
+            matchers.append(DocTestMatches(
+                "%s\n...\nfor merging\ninto\n%s\n..." % (
+                    mp.merge_source.identity, mp.merge_target.identity),
+                flags=doctest.ELLIPSIS))
+        self.assertThat(
+            [extract_text(tag) for tag in find_tags_by_class(
+                 browser.contents, "bug-mp-summary")],
+            MatchesListwise(matchers))
+
+    def test_bug_page_shows_link(self):
+        # The bug-MP link is shown on the bug page.
+        bug = self.factory.makeBug()
+        title = bug.title
+        with person_logged_in(self.user):
+            self.bmp.linkBug(bug, self.user)
+        browser = self.getViewBrowser(self.bmp)
+        browser.getLink(title).click()
+        with person_logged_in(self.user):
+            self.assertMergeProposalLinks([self.bmp], browser)
+
+    def test_link_to_private_bug_only_shown_if_visible(self):
+        # The MP page only shows links to private bugs if the user can see
+        # the bugs in question.
+        bug = self.factory.makeBug(information_type=InformationType.USERDATA)
+        with admin_logged_in() as admin:
+            bugtask = bug.default_bugtask
+            self.bmp.linkBug(bug, admin)
+        admin_browser = self.getViewBrowser(self.bmp, user=admin)
+        with admin_logged_in():
+            self.assertBugLinks([bugtask], admin_browser)
+        user_browser = self.getViewBrowser(self.bmp)
+        self.assertBugLinks([], user_browser)
+
+    def test_unlink_from_merge_proposal(self):
+        # The MP page has a delete button to unlink the bug.
+        bug = self.factory.makeBug()
+        with person_logged_in(self.user):
+            self.bmp.linkBug(bug, self.user)
+        browser = self.getViewBrowser(self.bmp)
+        browser.getLink(url="+unlinkbug").click()
+        self.assertBugLinks([], browser)
+
+    def test_unlink_from_bug(self):
+        # The bug page has a delete button to unlink the MP.
+        bug = self.factory.makeBug()
+        with person_logged_in(self.user):
+            self.bmp.linkBug(bug, self.user)
+        browser = self.getViewBrowser(bug)
+        browser.getLink(url="+unlinkbug").click()
+        self.assertMergeProposalLinks([], browser)
+
+
 class TestBranchMergeProposalDeleteViewMixin:
     """Test the BranchMergeProposal deletion view."""
 

=== modified file 'lib/lp/code/interfaces/branchmergeproposal.py'
--- lib/lp/code/interfaces/branchmergeproposal.py	2016-06-25 09:54:49 +0000
+++ lib/lp/code/interfaces/branchmergeproposal.py	2016-06-25 09:54:49 +0000
@@ -695,6 +695,24 @@
         :param comments: The comments.
         """
 
+    @export_write_operation()
+    @operation_parameters(
+        # Really IBug, patched in _schema_circular_imports.py.
+        bug=Reference(schema=Interface))
+    @call_with(user=REQUEST_USER)
+    @operation_for_version('devel')
+    def linkBug(bug, user=None, check_permissions=True):
+        """Link a bug to this merge proposal."""
+
+    @export_write_operation()
+    @operation_parameters(
+        # Really IBug, patched in _schema_circular_imports.py.
+        bug=Reference(schema=Interface))
+    @call_with(user=REQUEST_USER)
+    @operation_for_version('devel')
+    def unlinkBug(bug, user=None, check_permissions=True):
+        """Unlink a bug from this merge proposal."""
+
 
 class IBranchMergeProposal(IBranchMergeProposalPublic,
                            IBranchMergeProposalView, IBranchMergeProposalEdit,

=== modified file 'lib/lp/code/javascript/branch.bugspeclinks.js'
--- lib/lp/code/javascript/branch.bugspeclinks.js	2012-08-09 04:56:41 +0000
+++ lib/lp/code/javascript/branch.bugspeclinks.js	2016-06-25 09:54:49 +0000
@@ -1,4 +1,4 @@
-/* Copyright 2012 Canonical Ltd.  This software is licensed under the
+/* Copyright 2012-2016 Canonical Ltd.  This software is licensed under the
  * GNU Affero General Public License version 3 (see the file LICENSE).
  *
  * Provide functionality for picking a bug.
@@ -12,9 +12,20 @@
 var superclass = Y.lp.bugs.bug_picker.BugPicker;
 
 /*
- * Extract the best candidate for a bug number from the branch name.
+ * Extract the best candidate for a bug number from the context.
  */
-namespace.extract_candidate_bug_id = function(branch_name) {
+namespace.extract_candidate_bug_id = function(context) {
+    var branch_name;
+    if (context.source_branch !== undefined) {
+        branch_name = context.source_branch.name;
+    } else if (context.source_git_path !== undefined) {
+        branch_name = context.source_git_path;
+        if (branch_name.indexOf('refs/heads/') === 0) {
+            branch_name = branch_name.slice('refs/heads/'.length);
+        }
+    } else {
+        branch_name = context.name;
+    }
     // Extract all the runs of numbers in the branch name and sort by
     // descending length.
     var chunks = branch_name.split(/\D/g).sort(function (a, b) {
@@ -61,7 +72,7 @@
         this.after('visibleChange', function() {
             if (this.get('visible')) {
                 var guessed_bug_id =
-                    namespace.extract_candidate_bug_id(LP.cache.context.name);
+                    namespace.extract_candidate_bug_id(LP.cache.context);
                 if (Y.Lang.isValue(guessed_bug_id)) {
                     this._search_input.set('value', guessed_bug_id);
                     // Select the pre-filled bug number (if any) so that it will

=== modified file 'lib/lp/code/javascript/tests/test_bugspeclinks.js'
--- lib/lp/code/javascript/tests/test_bugspeclinks.js	2015-10-22 00:13:43 +0000
+++ lib/lp/code/javascript/tests/test_bugspeclinks.js	2016-06-25 09:54:49 +0000
@@ -1,4 +1,4 @@
-/* Copyright 2012-2012 Canonical Ltd.  This software is licensed under the
+/* Copyright 2012-2016 Canonical Ltd.  This software is licensed under the
  * GNU Affero General Public License version 3 (see the file LICENSE). */
 
 YUI.add('lp.code.branch.bugspeclinks.test', function (Y) {
@@ -14,48 +14,74 @@
         test_no_bug_id_present: function() {
             // If nothing that looks like a bug ID is present, null is
             // returned.
-            Y.Assert.isNull(extract_candidate_bug_id('no-id-here'));
+            Y.Assert.isNull(extract_candidate_bug_id({'name': 'no-id-here'}));
         },
 
         test_short_digit_rund_ignored: function() {
-            Y.Assert.isNull(extract_candidate_bug_id('foo-1234-bar'));
+            Y.Assert.isNull(
+                extract_candidate_bug_id({'name': 'foo-1234-bar'}));
         },
 
         test_leading_zeros_disqualify_potential_ids: function() {
             // Since bug IDs can't start with zeros, any string of numbers
             // with a leading zero are not considered as a potential ID.
-            Y.Assert.isNull(extract_candidate_bug_id('foo-0123456-bar'));
+            Y.Assert.isNull(
+                extract_candidate_bug_id({'name': 'foo-0123456-bar'}));
             Y.Assert.areEqual(
-                extract_candidate_bug_id('foo-0123456-999999-bar'), '999999');
+                extract_candidate_bug_id({'name': 'foo-0123456-999999-bar'}),
+                '999999');
         },
 
         test_five_digit_bug_ids_are_extracted: function() {
             Y.Assert.areEqual(
-                extract_candidate_bug_id('foo-12345-bar'), '12345');
+                extract_candidate_bug_id({'name': 'foo-12345-bar'}), '12345');
         },
 
         test_six_digit_bug_ids_are_extracted: function() {
             Y.Assert.areEqual(
-                extract_candidate_bug_id('foo-123456-bar'), '123456');
+                extract_candidate_bug_id({'name': 'foo-123456-bar'}),
+                '123456');
         },
 
         test_seven_digit_bug_ids_are_extracted: function() {
             Y.Assert.areEqual(
-                extract_candidate_bug_id('foo-1234567-bar'), '1234567');
+                extract_candidate_bug_id({'name': 'foo-1234567-bar'}),
+                '1234567');
         },
 
         test_eight_digit_bug_ids_are_extracted: function() {
             Y.Assert.areEqual(
-                extract_candidate_bug_id('foo-12345678-bar'), '12345678');
+                extract_candidate_bug_id({'name': 'foo-12345678-bar'}),
+                '12345678');
         },
 
         test_longest_potential_id_is_extracted: function() {
             // Since there may be numbers other than a bug ID in a branch
             // name, we want to extract the longest string of digits.
             Y.Assert.areEqual(
-                extract_candidate_bug_id('bug-123456-take-2'), '123456');
-            Y.Assert.areEqual(
-                extract_candidate_bug_id('123456-1234567'), '1234567');
+                extract_candidate_bug_id({'name': 'bug-123456-take-2'}),
+                '123456');
+            Y.Assert.areEqual(
+                extract_candidate_bug_id({'name': '123456-1234567'}),
+                '1234567');
+        },
+
+        test_merge_proposal_bzr: function() {
+            // If the context is a Bazaar-based merge proposal, bug IDs are
+            // extracted from the source branch.
+            Y.Assert.areEqual(
+                extract_candidate_bug_id(
+                    {'source_branch': {'name': 'foo-123456-bar'}}),
+                '123456');
+        },
+
+        test_merge_proposal_git: function() {
+            // If the context is a Git-based merge proposal, bug IDs are
+            // extracted from the source reference path.
+            Y.Assert.areEqual(
+                extract_candidate_bug_id(
+                    {'source_git_path': 'refs/heads/foo-123456-bar'}),
+                '123456');
         }
 
     }));

=== modified file 'lib/lp/code/stories/branches/xx-branchmergeproposals.txt'
--- lib/lp/code/stories/branches/xx-branchmergeproposals.txt	2015-06-23 17:30:39 +0000
+++ lib/lp/code/stories/branches/xx-branchmergeproposals.txt	2016-06-25 09:54:49 +0000
@@ -88,6 +88,8 @@
     lp://dev/~name12/gnome-terminal/pushed
     To merge this branch:
     bzr merge lp://dev/~name12/gnome-terminal/klingon
+    Related bugs:
+    Link a bug report
 
 
 Editing a commit message
@@ -457,23 +459,25 @@
 in the source branch.
 
     >>> def print_bugs_and_specs(browser):
-    ...     links = find_tag_by_id(browser.contents,
-    ...         'related-bugs-and-blueprints')
-    ...     if links == None:
-    ...         print links
-    ...     else:
-    ...         print extract_text(links)
+    ...     for id in 'related-bugs', 'related-blueprints':
+    ...         links = find_tag_by_id(browser.contents, id)
+    ...         if links == None:
+    ...             print links
+    ...         else:
+    ...             print extract_text(links)
 
     >>> login('admin@xxxxxxxxxxxxx')
     >>> bmp = factory.makeBranchMergeProposal()
     >>> bmp_url = canonical_url(bmp)
     >>> logout()
 
-There shouldn't be a 'Linked bug reports and blueprints' section if there are
-none
+If there are no related bugs, the corresponding section should only show a
+"Link to bug report" link; if there are no related blueprints, there should
+be no corresponding section.
 
     >>> nopriv_browser.open(bmp_url)
     >>> print_bugs_and_specs(nopriv_browser)
+    Related bugs: Link a bug report
     None
 
     >>> login('admin@xxxxxxxxxxxxx')
@@ -485,7 +489,8 @@
 
     >>> nopriv_browser.open(bmp_url)
     >>> print_bugs_and_specs(nopriv_browser)
-    Related bugs and blueprints: Bug #...: Bug for linking Undecided New
+    Related bugs: Bug #...: Bug for linking Undecided New Link a bug report
+    None
 
 
 Target branch edge cases

=== modified file 'lib/lp/code/templates/branchmergeproposal-macros.pt'
--- lib/lp/code/templates/branchmergeproposal-macros.pt	2015-05-12 17:14:52 +0000
+++ lib/lp/code/templates/branchmergeproposal-macros.pt	2016-06-25 09:54:49 +0000
@@ -67,4 +67,63 @@
 
 </metal:active-reviews>
 
+<metal:bug-summary define-macro="bug-summary">
+
+  <tal:comment condition="nothing">
+    This macro requires the following defined variables:
+      proposal - the linked merge proposal
+      bug - the linked bug
+  </tal:comment>
+
+  <div class="bug-mp-summary"
+       tal:define="show_edit bug/required:launchpad.Edit"
+       tal:condition="proposal/required:launchpad.View">
+    <a tal:attributes="href proposal/merge_source/fmt:url"
+       class="sprite branch"
+       tal:content="proposal/merge_source/display_name"/>
+    <a title="Remove link"
+       tal:condition="show_edit"
+       tal:attributes="href string:${proposal/fmt:url}/+unlinkbug?field.bugs=${bug/id}">
+      <img src="/@@/remove" alt="Remove"/>
+    </a>
+    <div class="reviews">
+      <tal:merge-fragment
+          tal:replace="structure proposal/@@+summary-fragment"/>
+    </div>
+  </div>
+
+</metal:bug-summary>
+
+<metal:bug-links define-macro="bug-links">
+
+  <table tal:condition="view/linked_bugtasks">
+    <tal:bug-tasks repeat="bugtask view/linked_bugtasks">
+      <tr tal:condition="bugtask/bug/required:launchpad.View"
+           tal:attributes="id string:buglink-${bugtask/bug/id}"
+           class="bug-mp-summary">
+        <td tal:content="structure bugtask/fmt:link:bugs" class="first"/>
+        <td>
+          <span tal:content="bugtask/importance/title"
+                tal:attributes="class string:importance${bugtask/importance/name}"
+                >Critical</span>
+        </td>
+        <td>
+          <span tal:content="bugtask/status/title"
+                tal:attributes="class string:status${bugtask/status/name}"
+                >Triaged</span>
+        </td>
+        <td>
+          <a title="Remove link"
+             class="delete-buglink"
+             tal:attributes="href string:+unlinkbug?field.bugs=${bugtask/bug/id};
+                             id string:delete-buglink-${bugtask/bug/id}">
+            <img src="/@@/remove" alt="Remove"/>
+          </a>
+        </td>
+      </tr>
+    </tal:bug-tasks>
+  </table>
+
+</metal:bug-links>
+
 </tal:root>

=== modified file 'lib/lp/code/templates/branchmergeproposal-pagelet-summary.pt'
--- lib/lp/code/templates/branchmergeproposal-pagelet-summary.pt	2015-08-28 15:03:12 +0000
+++ lib/lp/code/templates/branchmergeproposal-pagelet-summary.pt	2016-06-25 09:54:49 +0000
@@ -129,11 +129,40 @@
       <th>To merge this branch:</th>
       <td>bzr merge <span class="branch-url" tal:content="context/source_branch/bzr_identity" /></td>
     </tr>
-    <tr id="related-bugs-and-blueprints" tal:condition="view/has_bug_or_spec" >
-      <th>Related bugs and blueprints:</th>
+    <tr id="related-bugs">
+      <th>Related bugs:</th>
+      <td>
+        <metal:bug-links use-macro="context/@@+bmp-macros/bug-links"/>
+        <div tal:define="link context_menu/link_bug"
+             tal:condition="link/enabled">
+          <a id="linkbug"
+             class="sprite add"
+             tal:attributes="href link/url"
+             tal:content="link/text" />
+        </div>
+        <script type="text/javascript">
+          LPJS.use('io-base', 'lp.code.branch.bugspeclinks', function(Y) {
+            Y.on('domready', function() {
+              var logged_in = LP.links['me'] !== undefined;
+
+              if (logged_in) {
+                var config = {
+                  picker_activator = '#linkbug'
+                };
+                var linked_bug_picker = new
+                  Y.lp.code.branch.bugspeclinks.LinkedBugPicker(config);
+                linked_bug_picker.render();
+                linked_bug_picker.hide();
+              }
+            });
+          });
+        </script>
+      </td>
+    </tr>
+    <tr id="related-blueprints" tal:condition="view/has_specs">
+      <th>Related blueprints:</th>
       <td tal:define="branch context/source_branch">
-            <metal:bug-branch-links use-macro="branch/@@+macros/bug-branch-links"/>
-            <metal:spec-branch-links use-macro="branch/@@+macros/spec-branch-links"/>
+        <metal:spec-branch-links use-macro="branch/@@+macros/spec-branch-links"/>
       </td>
     </tr>
   </tbody>

=== added file 'lib/lp/code/templates/branchmergeproposal-unlinkbugs.pt'
--- lib/lp/code/templates/branchmergeproposal-unlinkbugs.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/branchmergeproposal-unlinkbugs.pt	2016-06-25 09:54:49 +0000
@@ -0,0 +1,27 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  xml:lang="en"
+  lang="en"
+  dir="ltr"
+  metal:use-macro="context/@@unlinkbugs_template/master"
+  i18n:domain="launchpad">
+
+  <body>
+    <div class="documentDescription" metal:fill-slot="extra_info">
+      <div>
+        <a tal:attributes="href context/fmt:url">
+          <strong>
+            <tal:merge-source replace="context/merge_source/identity"/>
+          </strong>
+          &rArr;
+          <tal:merge-target replace="context/merge_target/identity"/>
+        </a>
+      </div>
+      This will <em>remove</em> the link between the merge proposal and the
+      selected bug reports.
+    </div>
+  </body>
+</html>


Follow ups