Merge lp:~abentley/bzr/lpsubmit into lp:bzr

Proposed by Aaron Bentley on 2010-02-08
Status: Merged
Approved by: Martin Pool on 2010-02-17
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~abentley/bzr/lpsubmit
Merge into: lp:bzr
Diff against target: 496 lines (+427/-2)
4 files modified
NEWS (+3/-0)
bzrlib/plugins/launchpad/__init__.py (+71/-2)
bzrlib/plugins/launchpad/lp_api.py (+147/-0)
bzrlib/plugins/launchpad/lp_propose.py (+206/-0)
To merge this branch: bzr merge lp:~abentley/bzr/lpsubmit
Reviewer Review Type Date Requested Status
Martin Pool Needs Fixing on 2010-02-17
John A Meinel 2010-02-08 Approve on 2010-02-09
Review via email: mp+18876@code.launchpad.net

Commit Message

Add lp-submit command.

To post a comment you must log in.
Aaron Bentley (abentley) wrote :

Hi all,

This branch introduces the lp-submit command, originally from the bzr-pipeline
plugin.

It includes two hooks, one for determining the prerequisite branch, and one
for determining the message body. The pipeline plugin will use the first
to supply the previous pipe as the prerequisite branch. The lpreview_body
plugin will use the second to supply a body following Launchpad policies.

This merge proposal was, itself, submitted using the lp-submit command.

John A Meinel (jameinel) wrote :

I didn't focus too concretely on this, since it is part of the Launchpad plugin. (So the review and coding standards are looser than for bzrlib code.)

Some comments

1) There doesn't seem to be a way to supply the initial merge comment from the command line, as '-m' sets the commit message instead. *My* initial expectation was that 'bzr lp-submit -m ...' would have set the initial comment (and not popped up an editor). Though probably that is because *bzr* doesn't use the Commit Message field in Launchpad for anything. (We set it at pqm-submit time.)

However, using -m is probably fine, as it does mimic pqm-submit -m and commit -m for a system like Tarmac.

I would think you'd want a similar --comment="And here is my summary comment" flag, though.

2) The code seems fine. No tests, but it generally needs to be tested against Launchpad anyway.

review: Approve
Martin Pool (mbp) wrote :

This would be good to add.

Needs news.

Needs a mention in the user guide.

The help for cmd_lp_submit doesn't really explain what this does or why you would want to use it. It looks like creates a merge proposal or requests a merge - that's what the Launchpad UI calls it but this seems to have no connection.

review: Needs Fixing
Martin Pool (mbp) wrote :

It also ought to document, eg by way of an example, or in the option help, what the reviewers option does and its syntax.

Would it be better to just call this "lp-propose" rather than "lp-submit"? To me "submit" is equally likely to mean 'submit to pqm' after you're done.

lp_submit.py needs a copyright statement.

415 + except restful_errors.HTTPError, e:
416 + for line in e.content.splitlines():
417 + if line.startswith('Traceback (most recent call last):'):
418 + break
419 + print line

This should probably turn it into an error raised to the caller?

The following are optional or follow-ons:

It would be nice to test it at least down to the layer of the lp api, either through function calls or a --dry-run option.

some of the lp code could be in common parts, but it can be refactored later.

review: Needs Fixing
Martin Pool (mbp) wrote :

Oh, also, I suppose this should say that it will then open the mp in a browser.

As a follow-on one might want the option to turn this off.

Martin Pool (mbp) wrote :

I tested this interactively and it's really very nice, cleaning up a snag in the process.

It takes a while to get going so it would be good if it said just what it is doing.

It would also be nice if the message editor told me about the revisions newly added and perhaps showed their diff - in fact that would be a bit of a killer feature over Launchpad.

Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

John A Meinel wrote:
> *My* initial expectation was that 'bzr lp-submit -m ...' would have set the initial comment (and not popped up an editor). Though probably that is because *bzr* doesn't use the Commit Message field in Launchpad for anything. (We set it at pqm-submit time.)

The new lp-land command in bzr-pqm does use the commit message field.

> I would think you'd want a similar --comment="And here is my summary comment" flag, though.

So far, I haven't wanted that. In fact, one of the major advantages of
the command is that, like "bzr send" it allows a template proposal
message to be supplied. Launchpad's hook even includes lint output.
Providing an initial merge comment at the command line loses that
advantage. So I'm not sure it's worthwhile, though of course I wouldn't
block a reasonable implementation of it, if someone wanted to do that.

Aaron
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.9 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org

iEYEARECAAYFAktyMlYACgkQ0F+nu1YWqI1Z+ACfRk1K5pPQ2F9JTpafxeGugR96
gKEAnA6F6BspWrcRIIx4yQKcb5HrLOZF
=Lzkm
-----END PGP SIGNATURE-----

Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

Martin Pool wrote:
> Review: Needs Fixing
> It also ought to document, eg by way of an example, or in the option help, what the reviewers option does and its syntax.

Done.

> Would it be better to just call this "lp-propose" rather than "lp-submit"?

I've gone for maximum clarity and called it lp-propose-merge, with
lp-propose and lp-submit as aliases.

> To me "submit" is equally likely to mean 'submit to pqm' after you're done.

To me, the effect of submitting something should depend on what you're
submitting it to.

> lp_submit.py needs a copyright statement.

Done.

> 415 + except restful_errors.HTTPError, e:
> 416 + for line in e.content.splitlines():
> 417 + if line.startswith('Traceback (most recent call last):'):
> 418 + break
> 419 + print line
>
> This should probably turn it into an error raised to the caller?

Done.

> The following are optional or follow-ons:

I'll leave those for later.

> Oh, also, I suppose this should say that it will then open the mp in a browser.

Done.

> As a follow-on one might want the option to turn this off.

I'll leave that for later. (For the record, I *always* want it opened
in a browser, because once it's been proposed, I need to communicate
with potential reviewers about it.)

> It takes a while to get going so it would be good if it said just
> what it is doing.

I suppose. The sad thing is, AFAICT, the delay is the time it takes to
log into Launchpad.

> It would also be nice if the message editor told me about the
> revisions newly added and perhaps showed their diff - in fact that would
> be a bit of a killer feature over Launchpad.

Yes, that could be nice, but I prefer to see the diff in a terminal.

Aaron
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.9 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org

iEYEARECAAYFAktyNkUACgkQ0F+nu1YWqI0XEQCfUfLCZ7Ct2owzZdzTs+VmJBsL
yKEAn0W8WD4vZVLVbf7RjdDI2NhW3ZwA
=XbZJ
-----END PGP SIGNATURE-----

Aaron Bentley (abentley) wrote :

About the user guide: it's in the command listing; isn't that enough? If not, where exactly do you want it to go?

Martin Pool (mbp) wrote :

I used this for all my proposals yesterday and it was really nice - thanks!

> > Would it be better to just call this "lp-propose" rather than "lp-submit"?
>
> I've gone for maximum clarity and called it lp-propose-merge, with
> lp-propose and lp-submit as aliases.

That's good, thanks.

> > To me "submit" is equally likely to mean 'submit to pqm' after you're done.
>
> To me, the effect of submitting something should depend on what you're
> submitting it to.

I don't understand - we could use 'submit' to mean 'commit' when you're submitting from a tree to a branch, but calling that 'submit' would just cause confusion.

I think having multiple synonyms for commands (that aren't abbreviations) was a mistake and just causes confusion about whether they're the same or not.

So unless there's some reason I'm not aware of for 'lp-submit' please remove that alias.

> > It takes a while to get going so it would be good if it said just
> > what it is doing.
>
> I suppose. The sad thing is, AFAICT, the delay is the time it takes to
> log into Launchpad.

(In passing https://bugs.edge.launchpad.net/launchpadlib/+bug/520219)

review: Needs Fixing
Martin Pool (mbp) wrote :

> About the user guide: it's in the command listing; isn't that enough? If not,
> where exactly do you want it to go?

I think it should go into 'sharing with peers' in the user guide. Just a sentence or two making people aware that it exists is enough to let them chase the link to the help or user reference.

Also we should add it to help.l.n

Andrew Bennetts (spiv) wrote :

Martin Pool wrote:
[...]
> I think having multiple synonyms for commands (that aren't abbreviations) was
> a mistake and just causes confusion about whether they're the same or not.

Agreed — and I believe we've started taking steps to remove some of the
synonyms we already have, so adding more (even in a plugin) seems like a
step backwards.

Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

Martin Pool wrote:
>>> To me "submit" is equally likely to mean 'submit to pqm' after you're done.
>> To me, the effect of submitting something should depend on what you're
>> submitting it to.
>
> I don't understand - we could use 'submit' to mean 'commit' when you're submitting from a tree to a branch, but calling that 'submit' would just cause confusion.

I mean that 'submit' as a verb doesn't really suggest an outcome. If
you submit to a magazine, you may get an article published. If you
submit a premise, you may win an argument. If you submit to a PQM, you
may get your branch merged and committed. If you submit to Launchpad,
you may get your code reviewed. Note that whatever we call the command,
we will default to the "submit" branch as the target.

> I think having multiple synonyms for commands (that aren't abbreviations) was a mistake and just causes confusion about whether they're the same or not.
>
> So unless there's some reason I'm not aware of for 'lp-submit' please remove that alias.

The lp-submit command is well-established in the "bzr-pipeline" plugin.
 The alias is to ease the transition for people used to using that name.

Aaron
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.9 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org

iEYEARECAAYFAktzpt0ACgkQ0F+nu1YWqI1CrACdGPjcjfLFwchodYLazuwhSX8p
ELsAnj+2L5G6jncoECjYG1DmPtjjmZIh
=cT4L
-----END PGP SIGNATURE-----

Martin Pool (mbp) wrote :

> -----BEGIN PGP SIGNED MESSAGE-----
> Hash: SHA1
>
> Martin Pool wrote:
> >>> To me "submit" is equally likely to mean 'submit to pqm' after you're
> done.
> >> To me, the effect of submitting something should depend on what you're
> >> submitting it to.
> >
> > I don't understand - we could use 'submit' to mean 'commit' when you're
> submitting from a tree to a branch, but calling that 'submit' would just cause
> confusion.
>
> I mean that 'submit' as a verb doesn't really suggest an outcome. If
> you submit to a magazine, you may get an article published. If you
> submit a premise, you may win an argument. If you submit to a PQM, you
> may get your branch merged and committed. If you submit to Launchpad,
> you may get your code reviewed. Note that whatever we call the command,
> we will default to the "submit" branch as the target.

But this is just to say that submit is a very general verb, whereas if we're going to use it as the name of a command then having a more specific one is good.

Anyhow, changing the primary name and keeping the alias for compatibility is ok.

I would like to see the actual code changed to be 'propose' not 'submit'.

I would change canonical_url to something like web_url and make the replacement be

  launchpad_object.self_link.replace('api.', '', 1).replace('/beta/', '/', 1)

to make it more robust, and maybe add a comment pointing to https://bugs.edge.launchpad.net/launchpadlib/+bug/316694

aside from that, good to go, thanks.

review: Needs Fixing
lp:~abentley/bzr/lpsubmit updated on 2010-02-18
4986. By Aaron Bentley on 2010-02-18

Merged bzr.dev into lpsubmit.

4987. By Aaron Bentley on 2010-02-18

Fix divergence check.

4988. By Aaron Bentley on 2010-02-18

Rename submit to propose everywhere.

4989. By Aaron Bentley on 2010-02-18

Fix exception handling.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'NEWS'
2--- NEWS 2010-02-18 04:04:19 +0000
3+++ NEWS 2010-02-18 04:28:14 +0000
4@@ -49,6 +49,9 @@
5 as resolved is still accessible via the ``--done`` default action.
6 (Vincent Ladeuil)
7
8+* Merges can be proposed on Launchpad with the new lp-propose-merge command.
9+ (Aaron Bentley, Jonathan Lange)
10+
11 Bug Fixes
12 *********
13
14
15=== modified file 'bzrlib/plugins/launchpad/__init__.py'
16--- bzrlib/plugins/launchpad/__init__.py 2009-12-17 04:35:06 +0000
17+++ bzrlib/plugins/launchpad/__init__.py 2010-02-18 04:28:14 +0000
18@@ -1,4 +1,4 @@
19-# Copyright (C) 2006 - 2008 Canonical Ltd
20+# Copyright (C) 2006 - 2010 Canonical Ltd
21 #
22 # This program is free software; you can redistribute it and/or modify
23 # it under the terms of the GNU General Public License as published by
24@@ -32,7 +32,11 @@
25 )
26 """)
27
28-from bzrlib.commands import Command, Option, register_command
29+from bzrlib import bzrdir
30+from bzrlib.commands import (
31+ Command,
32+ register_command,
33+)
34 from bzrlib.directory_service import directories
35 from bzrlib.errors import (
36 BzrCommandError,
37@@ -42,6 +46,10 @@
38 NotBranchError,
39 )
40 from bzrlib.help_topics import topic_registry
41+from bzrlib.option import (
42+ Option,
43+ ListOption,
44+)
45
46
47 class cmd_register_branch(Command):
48@@ -277,6 +285,67 @@
49 register_command(cmd_launchpad_mirror)
50
51
52+class cmd_lp_propose_merge(Command):
53+ """Propose merging a branch on Launchpad.
54+
55+ This will open your usual editor to provide the initial comment. When it
56+ has created the proposal, it will open it in your default web browser.
57+
58+ The branch will be proposed to merge into SUBMIT_BRANCH. If SUBMIT_BRANCH
59+ is not supplied, the remembered submit branch will be used. If no submit
60+ branch is remembered, the development focus will be used.
61+
62+ By default, the SUBMIT_BRANCH's review team will be requested to review
63+ the merge proposal. This can be overriden by specifying --review (-R).
64+ The parameter the launchpad account name of the desired reviewer. This
65+ may optionally be followed by '=' and the review type. For example:
66+
67+ bzr lp-propose-merge --review jrandom --review review-team=qa
68+
69+ This will propose a merge, request "jrandom" to perform a review of
70+ unspecified type, and request "review-team" to perform a "qa" review.
71+ """
72+
73+ takes_options = [Option('staging',
74+ help='Propose the merge on staging.'),
75+ Option('message', short_name='m', type=unicode,
76+ help='Commit message.'),
77+ ListOption('review', short_name='R', type=unicode,
78+ help='Requested reviewer and optional type.')]
79+
80+ takes_args = ['submit_branch?']
81+
82+ aliases = ['lp-submit', 'lp-propose']
83+
84+ def run(self, submit_branch=None, review=None, staging=False,
85+ message=None):
86+ from bzrlib.plugins.launchpad import lp_propose
87+ tree, branch, relpath = bzrdir.BzrDir.open_containing_tree_or_branch(
88+ '.')
89+ if review is None:
90+ reviews = None
91+ else:
92+ reviews = []
93+ for review in review:
94+ if '=' in review:
95+ reviews.append(review.split('=', 2))
96+ else:
97+ reviews.append((review, ''))
98+ if submit_branch is None:
99+ submit_branch = branch.get_submit_branch()
100+ if submit_branch is None:
101+ target = None
102+ else:
103+ target = _mod_branch.Branch.open(submit_branch)
104+ proposer = lp_propose.Proposer(tree, branch, target, message,
105+ reviews, staging)
106+ proposer.check_proposal()
107+ proposer.create_proposal()
108+
109+
110+register_command(cmd_lp_propose_merge)
111+
112+
113 def _register_directory():
114 directories.register_lazy('lp:', 'bzrlib.plugins.launchpad.lp_directory',
115 'LaunchpadDirectory',
116
117=== modified file 'bzrlib/plugins/launchpad/lp_api.py'
118--- bzrlib/plugins/launchpad/lp_api.py 2009-12-17 03:40:41 +0000
119+++ bzrlib/plugins/launchpad/lp_api.py 2010-02-18 04:28:14 +0000
120@@ -22,11 +22,15 @@
121
122
123 import os
124+import re
125
126 from bzrlib import (
127+ branch,
128 config,
129 errors,
130 osutils,
131+ trace,
132+ transport,
133 )
134 from bzrlib.plugins.launchpad.lp_registration import (
135 InvalidLaunchpadInstance,
136@@ -112,6 +116,149 @@
137 return launchpad
138
139
140+class LaunchpadBranch(object):
141+ """Provide bzr and lp API access to a Launchpad branch."""
142+
143+ def __init__(self, lp_branch, bzr_url, bzr_branch=None, check_update=True):
144+ """Constructor.
145+
146+ :param lp_branch: The Launchpad branch.
147+ :param bzr_url: The URL of the Bazaar branch.
148+ :param bzr_branch: An instance of the Bazaar branch.
149+ """
150+ self.bzr_url = bzr_url
151+ self._bzr = bzr_branch
152+ self._push_bzr = None
153+ self._check_update = check_update
154+ self.lp = lp_branch
155+
156+ @property
157+ def bzr(self):
158+ """Return the bzr branch for this branch."""
159+ if self._bzr is None:
160+ self._bzr = branch.Branch.open(self.bzr_url)
161+ return self._bzr
162+
163+ @property
164+ def push_bzr(self):
165+ """Return the push branch for this branch."""
166+ if self._push_bzr is None:
167+ self._push_bzr = branch.Branch.open(self.lp.bzr_identity)
168+ return self._push_bzr
169+
170+ @staticmethod
171+ def plausible_launchpad_url(url):
172+ """Is 'url' something that could conceivably be pushed to LP?
173+
174+ :param url: A URL that may refer to a Launchpad branch.
175+ :return: A boolean.
176+ """
177+ if url is None:
178+ return False
179+ if url.startswith('lp:'):
180+ return True
181+ regex = re.compile('([a-z]*\+)*(bzr\+ssh|http)'
182+ '://bazaar.*.launchpad.net')
183+ return bool(regex.match(url))
184+
185+ @staticmethod
186+ def candidate_urls(bzr_branch):
187+ """Iterate through related URLs that might be Launchpad URLs.
188+
189+ :param bzr_branch: A Bazaar branch to find URLs from.
190+ :return: a generator of URL strings.
191+ """
192+ url = bzr_branch.get_public_branch()
193+ if url is not None:
194+ yield url
195+ url = bzr_branch.get_push_location()
196+ if url is not None:
197+ yield url
198+ yield bzr_branch.base
199+
200+ @staticmethod
201+ def tweak_url(url, launchpad):
202+ """Adjust a URL to work with staging, if needed."""
203+ if str(launchpad._root_uri) != STAGING_SERVICE_ROOT:
204+ return url
205+ if url is None:
206+ return None
207+ return url.replace('bazaar.launchpad.net',
208+ 'bazaar.staging.launchpad.net')
209+
210+ @classmethod
211+ def from_bzr(cls, launchpad, bzr_branch):
212+ """Find a Launchpad branch from a bzr branch."""
213+ check_update = True
214+ for url in cls.candidate_urls(bzr_branch):
215+ url = cls.tweak_url(url, launchpad)
216+ if not cls.plausible_launchpad_url(url):
217+ continue
218+ lp_branch = launchpad.branches.getByUrl(url=url)
219+ if lp_branch is not None:
220+ break
221+ else:
222+ lp_branch = cls.create_now(launchpad, bzr_branch)
223+ check_update = False
224+ return cls(lp_branch, bzr_branch.base, bzr_branch, check_update)
225+
226+ @classmethod
227+ def create_now(cls, launchpad, bzr_branch):
228+ """Create a Bazaar branch on Launchpad for the supplied branch."""
229+ url = cls.tweak_url(bzr_branch.get_push_location(), launchpad)
230+ if not cls.plausible_launchpad_url(url):
231+ raise errors.BzrError('%s is not registered on Launchpad' %
232+ bzr_branch.base)
233+ bzr_branch.create_clone_on_transport(transport.get_transport(url))
234+ lp_branch = launchpad.branches.getByUrl(url=url)
235+ if lp_branch is None:
236+ raise errors.BzrError('%s is not registered on Launchpad' % url)
237+ return lp_branch
238+
239+ def get_dev_focus(self):
240+ """Return the 'LaunchpadBranch' for the dev focus of this one."""
241+ lp_branch = self.lp
242+ if lp_branch.project is None:
243+ raise errors.BzrError('%s has no product.' %
244+ lp_branch.bzr_identity)
245+ dev_focus = lp_branch.project.development_focus.branch
246+ if dev_focus is None:
247+ raise errors.BzrError('%s has no development focus.' %
248+ lp_branch.bzr_identity)
249+ return LaunchpadBranch(dev_focus, dev_focus.bzr_identity)
250+
251+ def update_lp(self):
252+ """Update the Launchpad copy of this branch."""
253+ if not self._check_update:
254+ return
255+ self.bzr.lock_read()
256+ try:
257+ if self.lp.last_scanned_id is not None:
258+ if self.bzr.last_revision() == self.lp.last_scanned_id:
259+ trace.note('%s is already up-to-date.' %
260+ self.lp.bzr_identity)
261+ return
262+ graph = self.bzr.repository.get_graph()
263+ if not graph.is_ancestor(self.lp.last_scanned_id,
264+ self.bzr.last_revision()):
265+ raise errors.DivergedBranches(self.bzr, self.push_bzr)
266+ trace.note('Pushing to %s' % self.lp.bzr_identity)
267+ self.bzr.push(self.push_bzr)
268+ finally:
269+ self.bzr.unlock()
270+
271+ def find_lca_tree(self, other):
272+ """Find the revision tree for the LCA of this branch and other.
273+
274+ :param other: Another LaunchpadBranch
275+ :return: The RevisionTree of the LCA of this branch and other.
276+ """
277+ graph = self.bzr.repository.get_graph(other.bzr.repository)
278+ lca = graph.find_unique_lca(self.bzr.last_revision(),
279+ other.bzr.last_revision())
280+ return self.bzr.repository.revision_tree(lca)
281+
282+
283 def load_branch(launchpad, branch):
284 """Return the launchpadlib Branch object corresponding to 'branch'.
285
286
287=== added file 'bzrlib/plugins/launchpad/lp_propose.py'
288--- bzrlib/plugins/launchpad/lp_propose.py 1970-01-01 00:00:00 +0000
289+++ bzrlib/plugins/launchpad/lp_propose.py 2010-02-18 04:28:14 +0000
290@@ -0,0 +1,206 @@
291+# Copyright (C) 2009, 2010 Canonical Ltd
292+#
293+# This program is free software; you can redistribute it and/or modify
294+# it under the terms of the GNU General Public License as published by
295+# the Free Software Foundation; either version 2 of the License, or
296+# (at your option) any later version.
297+#
298+# This program is distributed in the hope that it will be useful,
299+# but WITHOUT ANY WARRANTY; without even the implied warranty of
300+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
301+# GNU General Public License for more details.
302+#
303+# You should have received a copy of the GNU General Public License
304+# along with this program; if not, write to the Free Software
305+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
306+
307+
308+import webbrowser
309+
310+from bzrlib import (
311+ errors,
312+ msgeditor,
313+)
314+from bzrlib.hooks import HookPoint, Hooks
315+from bzrlib.plugins.launchpad import (
316+ lp_api,
317+ lp_registration,
318+)
319+
320+from lazr.restfulclient import errors as restful_errors
321+
322+
323+class ProposeMergeHooks(Hooks):
324+ """Hooks for proposing a merge on Launchpad."""
325+
326+ def __init__(self):
327+ Hooks.__init__(self)
328+ self.create_hook(
329+ HookPoint(
330+ 'get_prerequisite',
331+ "Return the prerequisite branch for proposing as merge.",
332+ (2, 1), None),
333+ )
334+ self.create_hook(
335+ HookPoint(
336+ 'merge_proposal_body',
337+ "Return an initial body for the merge proposal message.",
338+ (2, 1), None),
339+ )
340+
341+
342+class Proposer(object):
343+
344+ hooks = ProposeMergeHooks()
345+
346+ def __init__(self, tree, source_branch, target_branch, message, reviews,
347+ staging=False):
348+ """Constructor.
349+
350+ :param tree: The working tree for the source branch.
351+ :param source_branch: The branch to propose for merging.
352+ :param target_branch: The branch to merge into.
353+ :param message: The commit message to use. (May be None.)
354+ :param reviews: A list of tuples of reviewer, review type.
355+ :param staging: If True, propose the merge against staging instead of
356+ production.
357+ """
358+ self.tree = tree
359+ if staging:
360+ lp_instance = 'staging'
361+ else:
362+ lp_instance = 'edge'
363+ service = lp_registration.LaunchpadService(lp_instance=lp_instance)
364+ self.launchpad = lp_api.login(service)
365+ self.source_branch = lp_api.LaunchpadBranch.from_bzr(
366+ self.launchpad, source_branch)
367+ if target_branch is None:
368+ self.target_branch = self.source_branch.get_dev_focus()
369+ else:
370+ self.target_branch = lp_api.LaunchpadBranch.from_bzr(
371+ self.launchpad, target_branch)
372+ self.commit_message = message
373+ if reviews == []:
374+ target_reviewer = self.target_branch.lp.reviewer
375+ if target_reviewer is None:
376+ raise errors.BzrCommandError('No reviewer specified')
377+ self.reviews = [(target_reviewer, '')]
378+ else:
379+ self.reviews = [(self.launchpad.people[reviewer], review_type)
380+ for reviewer, review_type in
381+ reviews]
382+
383+ def get_comment(self, prerequisite_branch):
384+ """Determine the initial comment for the merge proposal."""
385+ info = ["Source: %s\n" % self.source_branch.lp.bzr_identity]
386+ info.append("Target: %s\n" % self.target_branch.lp.bzr_identity)
387+ if prerequisite_branch is not None:
388+ info.append("Prereq: %s\n" % prerequisite_branch.lp.bzr_identity)
389+ for rdata in self.reviews:
390+ uniquename = "%s (%s)" % (rdata[0].display_name, rdata[0].name)
391+ info.append('Reviewer: %s, type "%s"\n' % (uniquename, rdata[1]))
392+ self.source_branch.bzr.lock_read()
393+ try:
394+ self.target_branch.bzr.lock_read()
395+ try:
396+ body = self.get_initial_body()
397+ finally:
398+ self.target_branch.bzr.unlock()
399+ finally:
400+ self.source_branch.bzr.unlock()
401+ initial_comment = msgeditor.edit_commit_message(''.join(info),
402+ start_message=body)
403+ return initial_comment.strip().encode('utf-8')
404+
405+ def get_initial_body(self):
406+ """Get a body for the proposal for the user to modify.
407+
408+ :return: a str or None.
409+ """
410+ def list_modified_files():
411+ lca_tree = self.source_branch.find_lca_tree(
412+ self.target_branch)
413+ source_tree = self.source_branch.bzr.basis_tree()
414+ files = modified_files(lca_tree, source_tree)
415+ return list(files)
416+ target_loc = ('bzr+ssh://bazaar.launchpad.net/%s' %
417+ self.target_branch.lp.unique_name)
418+ body = None
419+ for hook in self.hooks['merge_proposal_body']:
420+ body = hook({
421+ 'tree': self.tree,
422+ 'target_branch': target_loc,
423+ 'modified_files_callback': list_modified_files,
424+ 'old_body': body,
425+ })
426+ return body
427+
428+ def check_proposal(self):
429+ """Check that the submission is sensible."""
430+ if self.source_branch.lp.self_link == self.target_branch.lp.self_link:
431+ raise errors.BzrCommandError(
432+ 'Source and target branches must be different.')
433+ for mp in self.source_branch.lp.landing_targets:
434+ if mp.queue_status in ('Merged', 'Rejected'):
435+ continue
436+ if mp.target_branch.self_link == self.target_branch.lp.self_link:
437+ raise errors.BzrCommandError(
438+ 'There is already a branch merge proposal: %s' %
439+ canonical_url(mp))
440+
441+ def _get_prerequisite_branch(self):
442+ hooks = self.hooks['get_prerequisite']
443+ prerequisite_branch = None
444+ for hook in hooks:
445+ prerequisite_branch = hook(
446+ {'launchpad': self.launchpad,
447+ 'source_branch': self.source_branch,
448+ 'target_branch': self.target_branch,
449+ 'prerequisite_branch': prerequisite_branch})
450+ return prerequisite_branch
451+
452+ def create_proposal(self):
453+ """Perform the submission."""
454+ prerequisite_branch = self._get_prerequisite_branch()
455+ if prerequisite_branch is None:
456+ prereq = None
457+ else:
458+ prereq = prerequisite_branch.lp
459+ prerequisite_branch.update_lp()
460+ self.source_branch.update_lp()
461+ reviewers = []
462+ review_types = []
463+ for reviewer, review_type in self.reviews:
464+ review_types.append(review_type)
465+ reviewers.append(reviewer.self_link)
466+ initial_comment = self.get_comment(prerequisite_branch)
467+ try:
468+ mp = self.source_branch.lp.createMergeProposal(
469+ target_branch=self.target_branch.lp,
470+ prerequisite_branch=prereq,
471+ initial_comment=initial_comment,
472+ commit_message=self.commit_message, reviewers=reviewers,
473+ review_types=review_types)
474+ except restful_errors.HTTPError, e:
475+ error_lines = []
476+ for line in e.content.splitlines():
477+ if line.startswith('Traceback (most recent call last):'):
478+ break
479+ error_lines.append(line)
480+ raise Exception(''.join(error_lines))
481+ else:
482+ webbrowser.open(canonical_url(mp))
483+
484+
485+def modified_files(old_tree, new_tree):
486+ """Return a list of paths in the new tree with modified contents."""
487+ for f, (op, path), c, v, p, n, (ok, k), e in new_tree.iter_changes(
488+ old_tree):
489+ if c and k == 'file':
490+ yield str(path)
491+
492+
493+def canonical_url(object):
494+ """Return the canonical URL for a branch."""
495+ url = object.self_link.replace('https://api.', 'https://code.')
496+ return url.replace('/beta/', '/')