1
# Copyright (C) 2010 by Canonical Ltd
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License along
14
# with this program; if not, write to the Free Software Foundation, Inc.,
15
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
"""Tools for landing branches with Launchpad and Tarmac."""
21
from launchpadlib.launchpad import Launchpad
22
from launchpadlib.uris import (
23
DEV_SERVICE_ROOT, EDGE_SERVICE_ROOT,
24
LPNET_SERVICE_ROOT, STAGING_SERVICE_ROOT)
25
from lazr.uri import URI
26
from bzrlib.errors import BzrCommandError
27
from bzrlib import msgeditor
28
from bzrlib import smtp_connection
31
class MissingReviewError(Exception):
32
"""Raised when we try to get a review message without enough reviewers."""
35
class MissingBugsError(Exception):
36
"""Merge proposal has no linked bugs and no [no-qa] tag."""
39
class MissingBugsIncrementalError(Exception):
40
"""Merge proposal has the [incr] tag but no linked bugs."""
43
class LaunchpadBranchLander:
45
name = 'launchpad-branch-lander'
46
cache_dir = '~/.launchpadlib/cache'
48
def __init__(self, launchpad):
49
self._launchpad = launchpad
52
def load(cls, service_root=EDGE_SERVICE_ROOT):
53
# XXX: JonathanLange 2009-09-24: No unit tests.
54
cache_dir = os.path.expanduser(cls.cache_dir)
55
# XXX: JonathanLange 2009-09-24 bug=435813: If cached data invalid,
56
# there's no easy way to delete it and try again.
57
launchpad = Launchpad.login_with(cls.name, service_root, cache_dir)
60
def load_merge_proposal(self, mp_url):
61
"""Get the merge proposal object for the 'mp_url'."""
62
# XXX: JonathanLange 2009-09-24: No unit tests.
63
web_mp_uri = URI(mp_url)
64
api_mp_uri = self._launchpad._root_uri.append(
65
web_mp_uri.path.lstrip('/'))
66
return MergeProposal(self._launchpad.load(str(api_mp_uri)))
68
def get_lp_branch(self, branch):
69
"""Get the launchpadlib branch based on a bzr branch."""
70
# First try the public branch.
71
branch_url = branch.get_public_branch()
73
lp_branch = self._launchpad.branches.getByUrl(
75
if lp_branch is not None:
77
# If that didn't work try the push location.
78
branch_url = branch.get_push_location()
80
lp_branch = self._launchpad.branches.getByUrl(
82
if lp_branch is not None:
84
raise BzrCommandError(
85
"No public branch could be found. Please re-run and specify "
86
"the URL for the merge proposal.")
88
def get_merge_proposal_from_branch(self, branch):
89
"""Get the merge proposal from the branch."""
91
lp_branch = self.get_lp_branch(branch)
92
proposals = lp_branch.landing_targets
93
if len(proposals) == 0:
94
raise BzrCommandError(
95
"The public branch has no source merge proposals. "
96
"You must have a merge proposal before attempting to "
98
elif len(proposals) > 1:
99
raise BzrCommandError(
100
"The public branch has multiple source merge proposals. "
101
"You must provide the URL to the one you wish to use.")
102
return MergeProposal(proposals[0])
106
"""Wrapper around launchpadlib `IBranchMergeProposal` for landing."""
108
def __init__(self, mp):
109
"""Construct a merge proposal.
111
:param mp: A launchpadlib `IBranchMergeProposal`.
114
self._launchpad = mp._root
117
def source_branch(self):
118
"""The push URL of the source branch."""
119
return str(self._get_push_url(self._mp.source_branch)).rstrip('/')
122
def target_branch(self):
123
"""The push URL of the target branch."""
124
return str(self._get_push_url(self._mp.target_branch)).rstrip('/')
127
def commit_message(self):
128
"""The commit message specified on the merge proposal."""
129
return self._mp.commit_message
132
def is_approved(self):
133
"""Is this merge proposal approved for landing."""
134
return self._mp.queue_status == 'Approved'
136
def get_stakeholder_emails(self):
137
"""Return a collection of people who should know about branch landing.
139
Used to determine who to email with the ec2 test results.
141
:return: A set of `IPerson`s.
143
# XXX: JonathanLange 2009-09-24: No unit tests.
146
[self._mp.source_branch.owner, self._launchpad.me]))
148
def set_status_approved(self):
149
"""Set the overall status of the mp to Approved."""
151
self._mp.setStatus(status="Approved")
154
raise BzrCommandError(
155
"Unable to set the status in the merge proposal.\n"
158
def get_reviews(self):
159
"""Return a dictionary of all Approved reviews.
161
Used to determine who has actually approved a branch for landing. The
162
key of the dictionary is the type of review, and the value is the list
163
of people who have voted Approve with that type.
165
Common types include 'code', 'db', 'ui' and of course `None`.
168
for vote in self._mp.votes:
169
comment = vote.comment
170
if comment is None or comment.vote != "Approve":
172
reviewers = reviews.setdefault(vote.review_type, [])
173
reviewers.append(vote.reviewer)
177
"""Return a collection of bugs linked to the source branch."""
178
return self._mp.source_branch.linked_bugs
180
def _get_push_url(self, branch):
181
"""Return the push URL for 'branch'.
183
This function is a work-around for Launchpad's lack of exposing the
186
:param branch: A launchpadlib `IBranch`.
188
# XXX: JonathanLange 2009-09-24: No unit tests.
189
return branch.composePublicURL(scheme="bzr+ssh")
191
def build_commit_message(self, commit_text, testfix=False, no_qa=False,
192
incremental=False, rollback=None):
193
"""Get the Launchpad-style commit message for a merge proposal."""
194
reviews = self.get_reviews()
195
bugs = self.get_bugs()
198
get_testfix_clause(testfix),
199
get_reviewer_clause(reviews),
200
get_bugs_clause(bugs),
201
get_qa_clause(bugs, no_qa,
202
incremental, rollback=rollback),
205
# Make sure we don't add duplicated tags to commit_text.
206
commit_tags = tags[:]
208
if tag in commit_text:
209
commit_tags.remove(tag)
212
return '%s %s' % (''.join(commit_tags), commit_text)
216
def set_commit_message(self, commit_message):
217
"""Set the Launchpad-style commit message for a merge proposal."""
218
self._mp.commit_message = commit_message
222
class Submitter(object):
224
def __init__(self, branch, location, testfix=False, no_qa=False,
225
incremental=False, rollback=None):
227
self.testfix = testfix
229
self.incremental = incremental
230
self.rollback = rollback
231
self.config = self.branch.get_config()
232
self.lander = LaunchpadBranchLander.load()
233
self.location = location
236
def set_message(mp, testfix, no_qa, incremental, outf,
238
commit_message = mp.commit_message or ''
239
start_message = mp.build_commit_message(commit_message, testfix,
240
no_qa, incremental, rollback=rollback)
241
message = msgeditor.edit_commit_message(
242
"Submit to Tarmac: ",
243
start_message=start_message).rstrip('\n')
248
mp.set_commit_message(message)
250
raise BzrCommandError(
251
"Unable to set the commit message in the merge proposal.\n"
255
if self.location is None:
256
mp = self.lander.get_merge_proposal_from_branch(self.branch)
258
mp = self.lander.load_merge_proposal(self.location)
259
self.set_message(mp, self.testfix, self.no_qa,
260
self.incremental, outf, rollback=self.rollback)
262
mp.set_status_approved()
265
def get_email(person):
266
"""Get the preferred email address for 'person'."""
267
email_object = person.preferred_email_address
268
# XXX: JonathanLange 2009-09-24 bug=319432: This raises a very obscure
269
# error when the email address isn't set. e.g. with name12 in the sample
270
# data. e.g. "httplib2.RelativeURIError: Only absolute URIs are allowed.
271
# uri = tag:launchpad.net:2008:redacted".
272
return email_object.email
275
def get_bugs_clause(bugs):
276
"""Return the bugs clause of a commit message.
278
:param bugs: A collection of `IBug` objects.
279
:return: A string of the form "[bug=A,B,C]".
283
return '[bug=%s]' % ','.join(str(bug.id) for bug in bugs)
286
def get_testfix_clause(testfix=False):
287
"""Get the testfix clause."""
289
testfix_clause = '[testfix]'
292
return testfix_clause
295
def get_qa_clause(bugs, no_qa=False, incremental=False, rollback=None):
296
"""Check the no-qa and incremental options, getting the qa clause.
298
The qa clause will always be or no-qa, or incremental, or no-qa and
299
incremental, or a revno for the rollback clause, or no tags.
301
See https://dev.launchpad.net/QAProcessContinuousRollouts for detailed
302
explanation of each clause.
306
if not bugs and not no_qa and not incremental and not rollback:
307
raise MissingBugsError
309
if incremental and not bugs:
310
raise MissingBugsIncrementalError
312
if no_qa and incremental:
313
qa_clause = '[no-qa][incr]'
317
qa_clause = '[no-qa]'
319
qa_clause = '[rollback=%d]' % rollback
326
def get_reviewer_handle(reviewer):
327
"""Get the handle for 'reviewer'.
329
The handles of reviewers are included in the commit message for Launchpad
330
changes. Historically, these handles have been the IRC nicks. Thus, if
331
'reviewer' has an IRC nickname for Freenode, we use that. Otherwise we use
332
their Launchpad username.
334
:param reviewer: A launchpadlib `IPerson` object.
335
:return: unicode text.
337
irc_handles = reviewer.irc_nicknames
338
for handle in irc_handles:
339
if handle.network == 'irc.freenode.net':
340
return handle.nickname
344
def _comma_separated_names(things):
345
"""Return a string of comma-separated names of 'things'.
347
The list is sorted before being joined.
349
return ','.join(sorted(thing.name for thing in things))
352
def get_reviewer_clause(reviewers):
353
"""Get the reviewer section of a commit message, given the reviewers.
355
:param reviewers: A dict mapping review types to lists of reviewers, as
356
returned by 'get_reviews'.
357
:return: A string like u'[r=foo,bar][ui=plop]'.
359
# If no review type is specified it is assumed to be a code review.
360
code_reviewers = reviewers.get(None, [])
361
code_reviewers.extend(reviewers.get('', []))
364
for review_type, reviewer in reviewers.items():
365
if review_type is None:
367
if review_type == '':
368
code_reviewers.extend(reviewer)
369
if 'code' in review_type or 'db' in review_type:
370
code_reviewers.extend(reviewer)
371
if 'ui' in review_type:
372
ui_reviewers.extend(reviewer)
373
if 'release-critical' in review_type:
374
rc_reviewers.extend(reviewer)
375
if not code_reviewers:
376
raise MissingReviewError("Need approved votes in order to land.")
378
ui_clause = _comma_separated_names(ui_reviewers)
383
'[release-critical=%s]' % _comma_separated_names(rc_reviewers))
386
return '%s[r=%s][ui=%s]' % (
387
rc_clause, _comma_separated_names(code_reviewers), ui_clause)