Rev 5049: (abentley, jml) provide propose-merge command. in file:///home/pqm/archives/thelove/bzr/%2Btrunk/
Canonical.com Patch Queue Manager
pqm at pqm.ubuntu.com
Thu Feb 18 04:59:32 GMT 2010
At file:///home/pqm/archives/thelove/bzr/%2Btrunk/
------------------------------------------------------------
revno: 5049 [merge]
revision-id: pqm at pqm.ubuntu.com-20100218045930-ycl34opyvhowbskr
parent: pqm at pqm.ubuntu.com-20100218040419-l1gkx8dm60czagfq
parent: aaron at aaronbentley.com-20100218042630-s2xd2i9kzd8kld8j
committer: Canonical.com Patch Queue Manager <pqm at pqm.ubuntu.com>
branch nick: +trunk
timestamp: Thu 2010-02-18 04:59:30 +0000
message:
(abentley, jml) provide propose-merge command.
added:
bzrlib/plugins/launchpad/lp_propose.py lp_submit.py-20100120065117-penrmqruf596pui6-1
modified:
NEWS NEWS-20050323055033-4e00b5db738777ff
bzrlib/plugins/launchpad/__init__.py __init__.py-20060315182712-2d5feebd2a1032dc
bzrlib/plugins/launchpad/lp_api.py lp_api.py-20090704082908-79il6zl4gugwl3wz-1
=== modified file 'NEWS'
--- a/NEWS 2010-02-18 04:04:19 +0000
+++ b/NEWS 2010-02-18 04:59:30 +0000
@@ -49,6 +49,9 @@
as resolved is still accessible via the ``--done`` default action.
(Vincent Ladeuil)
+* Merges can be proposed on Launchpad with the new lp-propose-merge command.
+ (Aaron Bentley, Jonathan Lange)
+
Bug Fixes
*********
=== modified file 'bzrlib/plugins/launchpad/__init__.py'
--- a/bzrlib/plugins/launchpad/__init__.py 2009-12-17 04:35:06 +0000
+++ b/bzrlib/plugins/launchpad/__init__.py 2010-02-18 04:10:53 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 2006 - 2008 Canonical Ltd
+# Copyright (C) 2006 - 2010 Canonical Ltd
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -32,7 +32,11 @@
)
""")
-from bzrlib.commands import Command, Option, register_command
+from bzrlib import bzrdir
+from bzrlib.commands import (
+ Command,
+ register_command,
+)
from bzrlib.directory_service import directories
from bzrlib.errors import (
BzrCommandError,
@@ -42,6 +46,10 @@
NotBranchError,
)
from bzrlib.help_topics import topic_registry
+from bzrlib.option import (
+ Option,
+ ListOption,
+)
class cmd_register_branch(Command):
@@ -277,6 +285,67 @@
register_command(cmd_launchpad_mirror)
+class cmd_lp_propose_merge(Command):
+ """Propose merging a branch on Launchpad.
+
+ This will open your usual editor to provide the initial comment. When it
+ has created the proposal, it will open it in your default web browser.
+
+ The branch will be proposed to merge into SUBMIT_BRANCH. If SUBMIT_BRANCH
+ is not supplied, the remembered submit branch will be used. If no submit
+ branch is remembered, the development focus will be used.
+
+ By default, the SUBMIT_BRANCH's review team will be requested to review
+ the merge proposal. This can be overriden by specifying --review (-R).
+ The parameter the launchpad account name of the desired reviewer. This
+ may optionally be followed by '=' and the review type. For example:
+
+ bzr lp-propose-merge --review jrandom --review review-team=qa
+
+ This will propose a merge, request "jrandom" to perform a review of
+ unspecified type, and request "review-team" to perform a "qa" review.
+ """
+
+ takes_options = [Option('staging',
+ help='Propose the merge on staging.'),
+ Option('message', short_name='m', type=unicode,
+ help='Commit message.'),
+ ListOption('review', short_name='R', type=unicode,
+ help='Requested reviewer and optional type.')]
+
+ takes_args = ['submit_branch?']
+
+ aliases = ['lp-submit', 'lp-propose']
+
+ def run(self, submit_branch=None, review=None, staging=False,
+ message=None):
+ from bzrlib.plugins.launchpad import lp_propose
+ tree, branch, relpath = bzrdir.BzrDir.open_containing_tree_or_branch(
+ '.')
+ if review is None:
+ reviews = None
+ else:
+ reviews = []
+ for review in review:
+ if '=' in review:
+ reviews.append(review.split('=', 2))
+ else:
+ reviews.append((review, ''))
+ if submit_branch is None:
+ submit_branch = branch.get_submit_branch()
+ if submit_branch is None:
+ target = None
+ else:
+ target = _mod_branch.Branch.open(submit_branch)
+ proposer = lp_propose.Proposer(tree, branch, target, message,
+ reviews, staging)
+ proposer.check_proposal()
+ proposer.create_proposal()
+
+
+register_command(cmd_lp_propose_merge)
+
+
def _register_directory():
directories.register_lazy('lp:', 'bzrlib.plugins.launchpad.lp_directory',
'LaunchpadDirectory',
=== modified file 'bzrlib/plugins/launchpad/lp_api.py'
--- a/bzrlib/plugins/launchpad/lp_api.py 2009-12-17 03:40:41 +0000
+++ b/bzrlib/plugins/launchpad/lp_api.py 2010-02-18 03:44:44 +0000
@@ -22,11 +22,15 @@
import os
+import re
from bzrlib import (
+ branch,
config,
errors,
osutils,
+ trace,
+ transport,
)
from bzrlib.plugins.launchpad.lp_registration import (
InvalidLaunchpadInstance,
@@ -112,6 +116,149 @@
return launchpad
+class LaunchpadBranch(object):
+ """Provide bzr and lp API access to a Launchpad branch."""
+
+ def __init__(self, lp_branch, bzr_url, bzr_branch=None, check_update=True):
+ """Constructor.
+
+ :param lp_branch: The Launchpad branch.
+ :param bzr_url: The URL of the Bazaar branch.
+ :param bzr_branch: An instance of the Bazaar branch.
+ """
+ self.bzr_url = bzr_url
+ self._bzr = bzr_branch
+ self._push_bzr = None
+ self._check_update = check_update
+ self.lp = lp_branch
+
+ @property
+ def bzr(self):
+ """Return the bzr branch for this branch."""
+ if self._bzr is None:
+ self._bzr = branch.Branch.open(self.bzr_url)
+ return self._bzr
+
+ @property
+ def push_bzr(self):
+ """Return the push branch for this branch."""
+ if self._push_bzr is None:
+ self._push_bzr = branch.Branch.open(self.lp.bzr_identity)
+ return self._push_bzr
+
+ @staticmethod
+ def plausible_launchpad_url(url):
+ """Is 'url' something that could conceivably be pushed to LP?
+
+ :param url: A URL that may refer to a Launchpad branch.
+ :return: A boolean.
+ """
+ if url is None:
+ return False
+ if url.startswith('lp:'):
+ return True
+ regex = re.compile('([a-z]*\+)*(bzr\+ssh|http)'
+ '://bazaar.*.launchpad.net')
+ return bool(regex.match(url))
+
+ @staticmethod
+ def candidate_urls(bzr_branch):
+ """Iterate through related URLs that might be Launchpad URLs.
+
+ :param bzr_branch: A Bazaar branch to find URLs from.
+ :return: a generator of URL strings.
+ """
+ url = bzr_branch.get_public_branch()
+ if url is not None:
+ yield url
+ url = bzr_branch.get_push_location()
+ if url is not None:
+ yield url
+ yield bzr_branch.base
+
+ @staticmethod
+ def tweak_url(url, launchpad):
+ """Adjust a URL to work with staging, if needed."""
+ if str(launchpad._root_uri) != STAGING_SERVICE_ROOT:
+ return url
+ if url is None:
+ return None
+ return url.replace('bazaar.launchpad.net',
+ 'bazaar.staging.launchpad.net')
+
+ @classmethod
+ def from_bzr(cls, launchpad, bzr_branch):
+ """Find a Launchpad branch from a bzr branch."""
+ check_update = True
+ for url in cls.candidate_urls(bzr_branch):
+ url = cls.tweak_url(url, launchpad)
+ if not cls.plausible_launchpad_url(url):
+ continue
+ lp_branch = launchpad.branches.getByUrl(url=url)
+ if lp_branch is not None:
+ break
+ else:
+ lp_branch = cls.create_now(launchpad, bzr_branch)
+ check_update = False
+ return cls(lp_branch, bzr_branch.base, bzr_branch, check_update)
+
+ @classmethod
+ def create_now(cls, launchpad, bzr_branch):
+ """Create a Bazaar branch on Launchpad for the supplied branch."""
+ url = cls.tweak_url(bzr_branch.get_push_location(), launchpad)
+ if not cls.plausible_launchpad_url(url):
+ raise errors.BzrError('%s is not registered on Launchpad' %
+ bzr_branch.base)
+ bzr_branch.create_clone_on_transport(transport.get_transport(url))
+ lp_branch = launchpad.branches.getByUrl(url=url)
+ if lp_branch is None:
+ raise errors.BzrError('%s is not registered on Launchpad' % url)
+ return lp_branch
+
+ def get_dev_focus(self):
+ """Return the 'LaunchpadBranch' for the dev focus of this one."""
+ lp_branch = self.lp
+ if lp_branch.project is None:
+ raise errors.BzrError('%s has no product.' %
+ lp_branch.bzr_identity)
+ dev_focus = lp_branch.project.development_focus.branch
+ if dev_focus is None:
+ raise errors.BzrError('%s has no development focus.' %
+ lp_branch.bzr_identity)
+ return LaunchpadBranch(dev_focus, dev_focus.bzr_identity)
+
+ def update_lp(self):
+ """Update the Launchpad copy of this branch."""
+ if not self._check_update:
+ return
+ self.bzr.lock_read()
+ try:
+ if self.lp.last_scanned_id is not None:
+ if self.bzr.last_revision() == self.lp.last_scanned_id:
+ trace.note('%s is already up-to-date.' %
+ self.lp.bzr_identity)
+ return
+ graph = self.bzr.repository.get_graph()
+ if not graph.is_ancestor(self.lp.last_scanned_id,
+ self.bzr.last_revision()):
+ raise errors.DivergedBranches(self.bzr, self.push_bzr)
+ trace.note('Pushing to %s' % self.lp.bzr_identity)
+ self.bzr.push(self.push_bzr)
+ finally:
+ self.bzr.unlock()
+
+ def find_lca_tree(self, other):
+ """Find the revision tree for the LCA of this branch and other.
+
+ :param other: Another LaunchpadBranch
+ :return: The RevisionTree of the LCA of this branch and other.
+ """
+ graph = self.bzr.repository.get_graph(other.bzr.repository)
+ lca = graph.find_unique_lca(self.bzr.last_revision(),
+ other.bzr.last_revision())
+ return self.bzr.repository.revision_tree(lca)
+
+
def load_branch(launchpad, branch):
"""Return the launchpadlib Branch object corresponding to 'branch'.
=== added file 'bzrlib/plugins/launchpad/lp_propose.py'
--- a/bzrlib/plugins/launchpad/lp_propose.py 1970-01-01 00:00:00 +0000
+++ b/bzrlib/plugins/launchpad/lp_propose.py 2010-02-18 04:26:30 +0000
@@ -0,0 +1,206 @@
+# Copyright (C) 2009, 2010 Canonical Ltd
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+
+import webbrowser
+
+from bzrlib import (
+ errors,
+ msgeditor,
+)
+from bzrlib.hooks import HookPoint, Hooks
+from bzrlib.plugins.launchpad import (
+ lp_api,
+ lp_registration,
+)
+
+from lazr.restfulclient import errors as restful_errors
+
+
+class ProposeMergeHooks(Hooks):
+ """Hooks for proposing a merge on Launchpad."""
+
+ def __init__(self):
+ Hooks.__init__(self)
+ self.create_hook(
+ HookPoint(
+ 'get_prerequisite',
+ "Return the prerequisite branch for proposing as merge.",
+ (2, 1), None),
+ )
+ self.create_hook(
+ HookPoint(
+ 'merge_proposal_body',
+ "Return an initial body for the merge proposal message.",
+ (2, 1), None),
+ )
+
+
+class Proposer(object):
+
+ hooks = ProposeMergeHooks()
+
+ def __init__(self, tree, source_branch, target_branch, message, reviews,
+ staging=False):
+ """Constructor.
+
+ :param tree: The working tree for the source branch.
+ :param source_branch: The branch to propose for merging.
+ :param target_branch: The branch to merge into.
+ :param message: The commit message to use. (May be None.)
+ :param reviews: A list of tuples of reviewer, review type.
+ :param staging: If True, propose the merge against staging instead of
+ production.
+ """
+ self.tree = tree
+ if staging:
+ lp_instance = 'staging'
+ else:
+ lp_instance = 'edge'
+ service = lp_registration.LaunchpadService(lp_instance=lp_instance)
+ self.launchpad = lp_api.login(service)
+ self.source_branch = lp_api.LaunchpadBranch.from_bzr(
+ self.launchpad, source_branch)
+ if target_branch is None:
+ self.target_branch = self.source_branch.get_dev_focus()
+ else:
+ self.target_branch = lp_api.LaunchpadBranch.from_bzr(
+ self.launchpad, target_branch)
+ self.commit_message = message
+ if reviews == []:
+ target_reviewer = self.target_branch.lp.reviewer
+ if target_reviewer is None:
+ raise errors.BzrCommandError('No reviewer specified')
+ self.reviews = [(target_reviewer, '')]
+ else:
+ self.reviews = [(self.launchpad.people[reviewer], review_type)
+ for reviewer, review_type in
+ reviews]
+
+ def get_comment(self, prerequisite_branch):
+ """Determine the initial comment for the merge proposal."""
+ info = ["Source: %s\n" % self.source_branch.lp.bzr_identity]
+ info.append("Target: %s\n" % self.target_branch.lp.bzr_identity)
+ if prerequisite_branch is not None:
+ info.append("Prereq: %s\n" % prerequisite_branch.lp.bzr_identity)
+ for rdata in self.reviews:
+ uniquename = "%s (%s)" % (rdata[0].display_name, rdata[0].name)
+ info.append('Reviewer: %s, type "%s"\n' % (uniquename, rdata[1]))
+ self.source_branch.bzr.lock_read()
+ try:
+ self.target_branch.bzr.lock_read()
+ try:
+ body = self.get_initial_body()
+ finally:
+ self.target_branch.bzr.unlock()
+ finally:
+ self.source_branch.bzr.unlock()
+ initial_comment = msgeditor.edit_commit_message(''.join(info),
+ start_message=body)
+ return initial_comment.strip().encode('utf-8')
+
+ def get_initial_body(self):
+ """Get a body for the proposal for the user to modify.
+
+ :return: a str or None.
+ """
+ def list_modified_files():
+ lca_tree = self.source_branch.find_lca_tree(
+ self.target_branch)
+ source_tree = self.source_branch.bzr.basis_tree()
+ files = modified_files(lca_tree, source_tree)
+ return list(files)
+ target_loc = ('bzr+ssh://bazaar.launchpad.net/%s' %
+ self.target_branch.lp.unique_name)
+ body = None
+ for hook in self.hooks['merge_proposal_body']:
+ body = hook({
+ 'tree': self.tree,
+ 'target_branch': target_loc,
+ 'modified_files_callback': list_modified_files,
+ 'old_body': body,
+ })
+ return body
+
+ def check_proposal(self):
+ """Check that the submission is sensible."""
+ if self.source_branch.lp.self_link == self.target_branch.lp.self_link:
+ raise errors.BzrCommandError(
+ 'Source and target branches must be different.')
+ for mp in self.source_branch.lp.landing_targets:
+ if mp.queue_status in ('Merged', 'Rejected'):
+ continue
+ if mp.target_branch.self_link == self.target_branch.lp.self_link:
+ raise errors.BzrCommandError(
+ 'There is already a branch merge proposal: %s' %
+ canonical_url(mp))
+
+ def _get_prerequisite_branch(self):
+ hooks = self.hooks['get_prerequisite']
+ prerequisite_branch = None
+ for hook in hooks:
+ prerequisite_branch = hook(
+ {'launchpad': self.launchpad,
+ 'source_branch': self.source_branch,
+ 'target_branch': self.target_branch,
+ 'prerequisite_branch': prerequisite_branch})
+ return prerequisite_branch
+
+ def create_proposal(self):
+ """Perform the submission."""
+ prerequisite_branch = self._get_prerequisite_branch()
+ if prerequisite_branch is None:
+ prereq = None
+ else:
+ prereq = prerequisite_branch.lp
+ prerequisite_branch.update_lp()
+ self.source_branch.update_lp()
+ reviewers = []
+ review_types = []
+ for reviewer, review_type in self.reviews:
+ review_types.append(review_type)
+ reviewers.append(reviewer.self_link)
+ initial_comment = self.get_comment(prerequisite_branch)
+ try:
+ mp = self.source_branch.lp.createMergeProposal(
+ target_branch=self.target_branch.lp,
+ prerequisite_branch=prereq,
+ initial_comment=initial_comment,
+ commit_message=self.commit_message, reviewers=reviewers,
+ review_types=review_types)
+ except restful_errors.HTTPError, e:
+ error_lines = []
+ for line in e.content.splitlines():
+ if line.startswith('Traceback (most recent call last):'):
+ break
+ error_lines.append(line)
+ raise Exception(''.join(error_lines))
+ else:
+ webbrowser.open(canonical_url(mp))
+
+
+def modified_files(old_tree, new_tree):
+ """Return a list of paths in the new tree with modified contents."""
+ for f, (op, path), c, v, p, n, (ok, k), e in new_tree.iter_changes(
+ old_tree):
+ if c and k == 'file':
+ yield str(path)
+
+
+def canonical_url(object):
+ """Return the canonical URL for a branch."""
+ url = object.self_link.replace('https://api.', 'https://code.')
+ return url.replace('/beta/', '/')
More information about the bazaar-commits
mailing list