~ursinha/lp-qa-tools/bzr-tarmacland

« back to all changes in this revision

Viewing changes to lpland.py

  • Committer: Ursula Junque (Ursinha)
  • Date: 2010-12-16 03:14:26 UTC
  • Revision ID: ursinha@canonical.com-20101216031426-bbk2xmnt9vs8hvqz
Removing unnecessary files for the tarmac-land command.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2010 by Canonical Ltd
2
 
#
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.
7
 
#
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.
12
 
#
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.
16
 
 
17
 
"""Tools for landing branches with Launchpad."""
18
 
 
19
 
import os
20
 
 
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 import msgeditor
27
 
from bzrlib.errors import BzrCommandError
28
 
from bzrlib.plugins.pqm import pqm_submit
29
 
from bzrlib import smtp_connection
30
 
 
31
 
 
32
 
class MissingReviewError(Exception):
33
 
    """Raised when we try to get a review message without enough reviewers."""
34
 
 
35
 
 
36
 
class MissingBugsError(Exception):
37
 
    """Merge proposal has no linked bugs and no [no-qa] tag."""
38
 
 
39
 
 
40
 
class MissingBugsIncrementalError(Exception):
41
 
    """Merge proposal has the [incr] tag but no linked bugs."""
42
 
 
43
 
 
44
 
class LaunchpadBranchLander:
45
 
 
46
 
    name = 'launchpad-branch-lander'
47
 
    cache_dir = '~/.launchpadlib/cache'
48
 
 
49
 
    def __init__(self, launchpad):
50
 
        self._launchpad = launchpad
51
 
 
52
 
    @classmethod
53
 
    def load(cls, service_root=EDGE_SERVICE_ROOT):
54
 
        # XXX: JonathanLange 2009-09-24: No unit tests.
55
 
        cache_dir = os.path.expanduser(cls.cache_dir)
56
 
        # XXX: JonathanLange 2009-09-24 bug=435813: If cached data invalid,
57
 
        # there's no easy way to delete it and try again.
58
 
        launchpad = Launchpad.login_with(cls.name, service_root, cache_dir)
59
 
        return cls(launchpad)
60
 
 
61
 
    def load_merge_proposal(self, mp_url):
62
 
        """Get the merge proposal object for the 'mp_url'."""
63
 
        # XXX: JonathanLange 2009-09-24: No unit tests.
64
 
        web_mp_uri = URI(mp_url)
65
 
        api_mp_uri = self._launchpad._root_uri.append(
66
 
            web_mp_uri.path.lstrip('/'))
67
 
        return MergeProposal(self._launchpad.load(str(api_mp_uri)))
68
 
 
69
 
    def get_lp_branch(self, branch):
70
 
        """Get the launchpadlib branch based on a bzr branch."""
71
 
        # First try the public branch.
72
 
        branch_url = branch.get_public_branch()
73
 
        if branch_url:
74
 
            lp_branch = self._launchpad.branches.getByUrl(
75
 
                url=branch_url)
76
 
            if lp_branch is not None:
77
 
                return lp_branch
78
 
        # If that didn't work try the push location.
79
 
        branch_url = branch.get_push_location()
80
 
        if branch_url:
81
 
            lp_branch = self._launchpad.branches.getByUrl(
82
 
                url=branch_url)
83
 
            if lp_branch is not None:
84
 
                return lp_branch
85
 
        raise BzrCommandError(
86
 
            "No public branch could be found.  Please re-run and specify "
87
 
            "the URL for the merge proposal.")
88
 
 
89
 
    def get_merge_proposal_from_branch(self, branch):
90
 
        """Get the merge proposal from the branch."""
91
 
 
92
 
        lp_branch = self.get_lp_branch(branch)
93
 
        proposals = lp_branch.landing_targets
94
 
        if len(proposals) == 0:
95
 
            raise BzrCommandError(
96
 
                "The public branch has no source merge proposals.  "
97
 
                "You must have a merge proposal before attempting to "
98
 
                "land the branch.")
99
 
        elif len(proposals) > 1:
100
 
            raise BzrCommandError(
101
 
                "The public branch has multiple source merge proposals.  "
102
 
                "You must provide the URL to the one you wish to use.")
103
 
        return MergeProposal(proposals[0])
104
 
 
105
 
 
106
 
class MergeProposal:
107
 
    """Wrapper around launchpadlib `IBranchMergeProposal` for landing."""
108
 
 
109
 
    def __init__(self, mp):
110
 
        """Construct a merge proposal.
111
 
 
112
 
        :param mp: A launchpadlib `IBranchMergeProposal`.
113
 
        """
114
 
        self._mp = mp
115
 
        self._launchpad = mp._root
116
 
 
117
 
    @property
118
 
    def source_branch(self):
119
 
        """The push URL of the source branch."""
120
 
        return str(self._get_push_url(self._mp.source_branch)).rstrip('/')
121
 
 
122
 
    @property
123
 
    def target_branch(self):
124
 
        """The push URL of the target branch."""
125
 
        return str(self._get_push_url(self._mp.target_branch)).rstrip('/')
126
 
 
127
 
    @property
128
 
    def commit_message(self):
129
 
        """The commit message specified on the merge proposal."""
130
 
        return self._mp.commit_message
131
 
 
132
 
    @property
133
 
    def is_approved(self):
134
 
        """Is this merge proposal approved for landing."""
135
 
        return self._mp.queue_status == 'Approved'
136
 
 
137
 
    def get_stakeholder_emails(self):
138
 
        """Return a collection of people who should know about branch landing.
139
 
 
140
 
        Used to determine who to email with the ec2 test results.
141
 
 
142
 
        :return: A set of `IPerson`s.
143
 
        """
144
 
        # XXX: JonathanLange 2009-09-24: No unit tests.
145
 
        return set(
146
 
            map(get_email,
147
 
                [self._mp.source_branch.owner, self._launchpad.me]))
148
 
 
149
 
    def get_reviews(self):
150
 
        """Return a dictionary of all Approved reviews.
151
 
 
152
 
        Used to determine who has actually approved a branch for landing. The
153
 
        key of the dictionary is the type of review, and the value is the list
154
 
        of people who have voted Approve with that type.
155
 
 
156
 
        Common types include 'code', 'db', 'ui' and of course `None`.
157
 
        """
158
 
        reviews = {}
159
 
        for vote in self._mp.votes:
160
 
            comment = vote.comment
161
 
            if comment is None or comment.vote != "Approve":
162
 
                continue
163
 
            reviewers = reviews.setdefault(vote.review_type, [])
164
 
            reviewers.append(vote.reviewer)
165
 
        return reviews
166
 
 
167
 
    def get_bugs(self):
168
 
        """Return a collection of bugs linked to the source branch."""
169
 
        return self._mp.source_branch.linked_bugs
170
 
 
171
 
    def _get_push_url(self, branch):
172
 
        """Return the push URL for 'branch'.
173
 
 
174
 
        This function is a work-around for Launchpad's lack of exposing the
175
 
        branch's push URL.
176
 
 
177
 
        :param branch: A launchpadlib `IBranch`.
178
 
        """
179
 
        # XXX: JonathanLange 2009-09-24: No unit tests.
180
 
        return branch.composePublicURL(scheme="bzr+ssh")
181
 
 
182
 
    def build_commit_message(self, commit_text, testfix=False, no_qa=False,
183
 
                             incremental=False, rollback=None):
184
 
        """Get the Launchpad-style commit message for a merge proposal."""
185
 
        reviews = self.get_reviews()
186
 
        bugs = self.get_bugs()
187
 
 
188
 
        tags = [
189
 
            get_testfix_clause(testfix),
190
 
            get_reviewer_clause(reviews),
191
 
            get_bugs_clause(bugs),
192
 
            get_qa_clause(bugs, no_qa,
193
 
                incremental, rollback=rollback),
194
 
            ]
195
 
 
196
 
        # Make sure we don't add duplicated tags to commit_text.
197
 
        commit_tags = tags[:]
198
 
        for tag in tags:
199
 
            if tag in commit_text:
200
 
                commit_tags.remove(tag)
201
 
 
202
 
        if commit_tags:
203
 
            return '%s %s' % (''.join(commit_tags), commit_text)
204
 
        else:
205
 
            return commit_text
206
 
 
207
 
    def set_commit_message(self, commit_message):
208
 
        """Set the Launchpad-style commit message for a merge proposal."""
209
 
        self._mp.commit_message = commit_message
210
 
        self._mp.lp_save()
211
 
 
212
 
 
213
 
class Submitter(object):
214
 
 
215
 
    def __init__(self, branch, location, testfix=False, no_qa=False,
216
 
                 incremental=False, rollback=None):
217
 
        self.branch = branch
218
 
        self.testfix = testfix
219
 
        self.no_qa = no_qa
220
 
        self.incremental = incremental
221
 
        self.rollback = rollback
222
 
        self.config = self.branch.get_config()
223
 
        self.mail_to = self.config.get_user_option('pqm_email')
224
 
        if not self.mail_to:
225
 
            raise pqm_submit.NoPQMSubmissionAddress(branch)
226
 
        self.mail_from = self.config.get_user_option('pqm_user_email')
227
 
        if not self.mail_from:
228
 
            self.mail_from = self.config.username()
229
 
        self.lander = LaunchpadBranchLander.load()
230
 
        self.location = location
231
 
 
232
 
    def submission(self, mp):
233
 
        submission = pqm_submit.PQMSubmission(self.branch,
234
 
            mp.source_branch, mp.target_branch, '')
235
 
        return submission
236
 
 
237
 
    @staticmethod
238
 
    def check_submission(submission):
239
 
        submission.check_tree()
240
 
        submission.check_public_branch()
241
 
 
242
 
    @staticmethod
243
 
    def set_message(submission, mp, testfix, no_qa, incremental,
244
 
            rollback=None):
245
 
        pqm_command = ''.join(submission.to_lines())
246
 
        commit_message = mp.commit_message or ''
247
 
        start_message = mp.build_commit_message(commit_message, testfix,
248
 
            no_qa, incremental, rollback=rollback)
249
 
        try:
250
 
            mp.set_commit_message(start_message)
251
 
        except Exception, e:
252
 
            raise BzrCommandError(
253
 
                "Unable to set the commit message in the merge proposal.\n"
254
 
                "Got: %s" % e)
255
 
        message = msgeditor.edit_commit_message(
256
 
            'pqm command:\n%s' % pqm_command,
257
 
            start_message=start_message).rstrip('\n')
258
 
        submission.message = message.encode('utf-8')
259
 
 
260
 
    def run(self, outf):
261
 
        if self.location is None:
262
 
            mp = self.lander.get_merge_proposal_from_branch(self.branch)
263
 
        else:
264
 
            mp = self.lander.load_merge_proposal(self.location)
265
 
        submission = self.submission(mp)
266
 
        self.check_submission(submission)
267
 
        self.set_message(submission, mp, self.testfix, self.no_qa,
268
 
            self.incremental, rollback=self.rollback)
269
 
        email = submission.to_email(self.mail_from, self.mail_to)
270
 
        if outf is not None:
271
 
            outf.write(email.as_string())
272
 
        else:
273
 
            smtp_connection.SMTPConnection(self.config).send_email(email)
274
 
 
275
 
 
276
 
def get_email(person):
277
 
    """Get the preferred email address for 'person'."""
278
 
    email_object = person.preferred_email_address
279
 
    # XXX: JonathanLange 2009-09-24 bug=319432: This raises a very obscure
280
 
    # error when the email address isn't set. e.g. with name12 in the sample
281
 
    # data. e.g. "httplib2.RelativeURIError: Only absolute URIs are allowed.
282
 
    # uri = tag:launchpad.net:2008:redacted".
283
 
    return email_object.email
284
 
 
285
 
 
286
 
def get_bugs_clause(bugs):
287
 
    """Return the bugs clause of a commit message.
288
 
 
289
 
    :param bugs: A collection of `IBug` objects.
290
 
    :return: A string of the form "[bug=A,B,C]".
291
 
    """
292
 
    if not bugs:
293
 
        return ''
294
 
    return '[bug=%s]' % ','.join(str(bug.id) for bug in bugs)
295
 
 
296
 
 
297
 
def get_testfix_clause(testfix=False):
298
 
    """Get the testfix clause."""
299
 
    if testfix:
300
 
        testfix_clause = '[testfix]'
301
 
    else:
302
 
        testfix_clause = ''
303
 
    return testfix_clause
304
 
 
305
 
 
306
 
def get_qa_clause(bugs, no_qa=False, incremental=False, rollback=None):
307
 
    """Check the no-qa and incremental options, getting the qa clause.
308
 
 
309
 
    The qa clause will always be or no-qa, or incremental, or no-qa and
310
 
    incremental, or a revno for the rollback clause, or no tags.
311
 
 
312
 
    See https://dev.launchpad.net/QAProcessContinuousRollouts for detailed
313
 
    explanation of each clause.
314
 
    """
315
 
    qa_clause = ""
316
 
 
317
 
    if not bugs and not no_qa and not incremental and not rollback:
318
 
        raise MissingBugsError
319
 
 
320
 
    if incremental and not bugs:
321
 
        raise MissingBugsIncrementalError
322
 
 
323
 
    if no_qa and incremental:
324
 
        qa_clause = '[no-qa][incr]'
325
 
    elif incremental:
326
 
        qa_clause = '[incr]'
327
 
    elif no_qa:
328
 
        qa_clause = '[no-qa]'
329
 
    elif rollback:
330
 
        qa_clause = '[rollback=%d]' % rollback
331
 
    else:
332
 
        qa_clause = ''
333
 
 
334
 
    return qa_clause
335
 
 
336
 
 
337
 
def get_reviewer_handle(reviewer):
338
 
    """Get the handle for 'reviewer'.
339
 
 
340
 
    The handles of reviewers are included in the commit message for Launchpad
341
 
    changes. Historically, these handles have been the IRC nicks. Thus, if
342
 
    'reviewer' has an IRC nickname for Freenode, we use that. Otherwise we use
343
 
    their Launchpad username.
344
 
 
345
 
    :param reviewer: A launchpadlib `IPerson` object.
346
 
    :return: unicode text.
347
 
    """
348
 
    irc_handles = reviewer.irc_nicknames
349
 
    for handle in irc_handles:
350
 
        if handle.network == 'irc.freenode.net':
351
 
            return handle.nickname
352
 
    return reviewer.name
353
 
 
354
 
 
355
 
def _comma_separated_names(things):
356
 
    """Return a string of comma-separated names of 'things'.
357
 
 
358
 
    The list is sorted before being joined.
359
 
    """
360
 
    return ','.join(sorted(thing.name for thing in things))
361
 
 
362
 
 
363
 
def get_reviewer_clause(reviewers):
364
 
    """Get the reviewer section of a commit message, given the reviewers.
365
 
 
366
 
    :param reviewers: A dict mapping review types to lists of reviewers, as
367
 
        returned by 'get_reviews'.
368
 
    :return: A string like u'[r=foo,bar][ui=plop]'.
369
 
    """
370
 
    # If no review type is specified it is assumed to be a code review.
371
 
    code_reviewers = reviewers.get(None, [])
372
 
    code_reviewers.extend(reviewers.get('', []))
373
 
    ui_reviewers = []
374
 
    rc_reviewers = []
375
 
    for review_type, reviewer in reviewers.items():
376
 
        if review_type is None:
377
 
            continue
378
 
        if review_type == '':
379
 
            code_reviewers.extend(reviewer)
380
 
        if 'code' in review_type or 'db' in review_type:
381
 
            code_reviewers.extend(reviewer)
382
 
        if 'ui' in review_type:
383
 
            ui_reviewers.extend(reviewer)
384
 
        if 'release-critical' in review_type:
385
 
            rc_reviewers.extend(reviewer)
386
 
    if not code_reviewers:
387
 
        raise MissingReviewError("Need approved votes in order to land.")
388
 
    if ui_reviewers:
389
 
        ui_clause = _comma_separated_names(ui_reviewers)
390
 
    else:
391
 
        ui_clause = 'none'
392
 
    if rc_reviewers:
393
 
        rc_clause = (
394
 
            '[release-critical=%s]' % _comma_separated_names(rc_reviewers))
395
 
    else:
396
 
        rc_clause = ''
397
 
    return '%s[r=%s][ui=%s]' % (
398
 
        rc_clause, _comma_separated_names(code_reviewers), ui_clause)