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

« back to all changes in this revision

Viewing changes to tarmac_land.py

  • Committer: Ursula Junque (Ursinha)
  • Date: 2010-12-16 03:15:17 UTC
  • Revision ID: ursinha@canonical.com-20101216031517-sdfelqll9tzzy42g
Tarmac land command, based on lp-land removing the PQM related part.

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 and Tarmac."""
 
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.errors import BzrCommandError
 
27
from bzrlib import msgeditor
 
28
from bzrlib import smtp_connection
 
29
 
 
30
 
 
31
class MissingReviewError(Exception):
 
32
    """Raised when we try to get a review message without enough reviewers."""
 
33
 
 
34
 
 
35
class MissingBugsError(Exception):
 
36
    """Merge proposal has no linked bugs and no [no-qa] tag."""
 
37
 
 
38
 
 
39
class MissingBugsIncrementalError(Exception):
 
40
    """Merge proposal has the [incr] tag but no linked bugs."""
 
41
 
 
42
 
 
43
class LaunchpadBranchLander:
 
44
 
 
45
    name = 'launchpad-branch-lander'
 
46
    cache_dir = '~/.launchpadlib/cache'
 
47
 
 
48
    def __init__(self, launchpad):
 
49
        self._launchpad = launchpad
 
50
 
 
51
    @classmethod
 
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)
 
58
        return cls(launchpad)
 
59
 
 
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)))
 
67
 
 
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()
 
72
        if branch_url:
 
73
            lp_branch = self._launchpad.branches.getByUrl(
 
74
                url=branch_url)
 
75
            if lp_branch is not None:
 
76
                return lp_branch
 
77
        # If that didn't work try the push location.
 
78
        branch_url = branch.get_push_location()
 
79
        if branch_url:
 
80
            lp_branch = self._launchpad.branches.getByUrl(
 
81
                url=branch_url)
 
82
            if lp_branch is not None:
 
83
                return lp_branch
 
84
        raise BzrCommandError(
 
85
            "No public branch could be found.  Please re-run and specify "
 
86
            "the URL for the merge proposal.")
 
87
 
 
88
    def get_merge_proposal_from_branch(self, branch):
 
89
        """Get the merge proposal from the branch."""
 
90
 
 
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 "
 
97
                "land the branch.")
 
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])
 
103
 
 
104
 
 
105
class MergeProposal:
 
106
    """Wrapper around launchpadlib `IBranchMergeProposal` for landing."""
 
107
 
 
108
    def __init__(self, mp):
 
109
        """Construct a merge proposal.
 
110
 
 
111
        :param mp: A launchpadlib `IBranchMergeProposal`.
 
112
        """
 
113
        self._mp = mp
 
114
        self._launchpad = mp._root
 
115
 
 
116
    @property
 
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('/')
 
120
 
 
121
    @property
 
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('/')
 
125
 
 
126
    @property
 
127
    def commit_message(self):
 
128
        """The commit message specified on the merge proposal."""
 
129
        return self._mp.commit_message
 
130
 
 
131
    @property
 
132
    def is_approved(self):
 
133
        """Is this merge proposal approved for landing."""
 
134
        return self._mp.queue_status == 'Approved'
 
135
 
 
136
    def get_stakeholder_emails(self):
 
137
        """Return a collection of people who should know about branch landing.
 
138
 
 
139
        Used to determine who to email with the ec2 test results.
 
140
 
 
141
        :return: A set of `IPerson`s.
 
142
        """
 
143
        # XXX: JonathanLange 2009-09-24: No unit tests.
 
144
        return set(
 
145
            map(get_email,
 
146
                [self._mp.source_branch.owner, self._launchpad.me]))
 
147
 
 
148
    def set_status_approved(self):
 
149
        """Set the overall status of the mp to Approved."""
 
150
        try:
 
151
            self._mp.setStatus(status="Approved")
 
152
            self._mp.lp_save()
 
153
        except Exception, e:
 
154
            raise BzrCommandError(
 
155
                "Unable to set the status in the merge proposal.\n"
 
156
                "Got: %s" % e)
 
157
 
 
158
    def get_reviews(self):
 
159
        """Return a dictionary of all Approved reviews.
 
160
 
 
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.
 
164
 
 
165
        Common types include 'code', 'db', 'ui' and of course `None`.
 
166
        """
 
167
        reviews = {}
 
168
        for vote in self._mp.votes:
 
169
            comment = vote.comment
 
170
            if comment is None or comment.vote != "Approve":
 
171
                continue
 
172
            reviewers = reviews.setdefault(vote.review_type, [])
 
173
            reviewers.append(vote.reviewer)
 
174
        return reviews
 
175
 
 
176
    def get_bugs(self):
 
177
        """Return a collection of bugs linked to the source branch."""
 
178
        return self._mp.source_branch.linked_bugs
 
179
 
 
180
    def _get_push_url(self, branch):
 
181
        """Return the push URL for 'branch'.
 
182
 
 
183
        This function is a work-around for Launchpad's lack of exposing the
 
184
        branch's push URL.
 
185
 
 
186
        :param branch: A launchpadlib `IBranch`.
 
187
        """
 
188
        # XXX: JonathanLange 2009-09-24: No unit tests.
 
189
        return branch.composePublicURL(scheme="bzr+ssh")
 
190
 
 
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()
 
196
 
 
197
        tags = [
 
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),
 
203
            ]
 
204
 
 
205
        # Make sure we don't add duplicated tags to commit_text.
 
206
        commit_tags = tags[:]
 
207
        for tag in tags:
 
208
            if tag in commit_text:
 
209
                commit_tags.remove(tag)
 
210
 
 
211
        if commit_tags:
 
212
            return '%s %s' % (''.join(commit_tags), commit_text)
 
213
        else:
 
214
            return commit_text
 
215
 
 
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
 
219
        self._mp.lp_save()
 
220
 
 
221
 
 
222
class Submitter(object):
 
223
 
 
224
    def __init__(self, branch, location, testfix=False, no_qa=False,
 
225
                 incremental=False, rollback=None):
 
226
        self.branch = branch
 
227
        self.testfix = testfix
 
228
        self.no_qa = no_qa
 
229
        self.incremental = incremental
 
230
        self.rollback = rollback
 
231
        self.config = self.branch.get_config()
 
232
        self.lander = LaunchpadBranchLander.load()
 
233
        self.location = location
 
234
 
 
235
    @staticmethod
 
236
    def set_message(mp, testfix, no_qa, incremental, outf,
 
237
            rollback=None):
 
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')
 
244
        if outf is not None:
 
245
            outf.write(message)
 
246
        else:
 
247
            try:
 
248
                mp.set_commit_message(message)
 
249
            except Exception, e:
 
250
                raise BzrCommandError(
 
251
                    "Unable to set the commit message in the merge proposal.\n"
 
252
                    "Got: %s" % e)
 
253
 
 
254
    def run(self, outf):
 
255
        if self.location is None:
 
256
            mp = self.lander.get_merge_proposal_from_branch(self.branch)
 
257
        else:
 
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)
 
261
        if outf is None:
 
262
            mp.set_status_approved()
 
263
 
 
264
 
 
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
 
273
 
 
274
 
 
275
def get_bugs_clause(bugs):
 
276
    """Return the bugs clause of a commit message.
 
277
 
 
278
    :param bugs: A collection of `IBug` objects.
 
279
    :return: A string of the form "[bug=A,B,C]".
 
280
    """
 
281
    if not bugs:
 
282
        return ''
 
283
    return '[bug=%s]' % ','.join(str(bug.id) for bug in bugs)
 
284
 
 
285
 
 
286
def get_testfix_clause(testfix=False):
 
287
    """Get the testfix clause."""
 
288
    if testfix:
 
289
        testfix_clause = '[testfix]'
 
290
    else:
 
291
        testfix_clause = ''
 
292
    return testfix_clause
 
293
 
 
294
 
 
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.
 
297
 
 
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.
 
300
 
 
301
    See https://dev.launchpad.net/QAProcessContinuousRollouts for detailed
 
302
    explanation of each clause.
 
303
    """
 
304
    qa_clause = ""
 
305
 
 
306
    if not bugs and not no_qa and not incremental and not rollback:
 
307
        raise MissingBugsError
 
308
 
 
309
    if incremental and not bugs:
 
310
        raise MissingBugsIncrementalError
 
311
 
 
312
    if no_qa and incremental:
 
313
        qa_clause = '[no-qa][incr]'
 
314
    elif incremental:
 
315
        qa_clause = '[incr]'
 
316
    elif no_qa:
 
317
        qa_clause = '[no-qa]'
 
318
    elif rollback:
 
319
        qa_clause = '[rollback=%d]' % rollback
 
320
    else:
 
321
        qa_clause = ''
 
322
 
 
323
    return qa_clause
 
324
 
 
325
 
 
326
def get_reviewer_handle(reviewer):
 
327
    """Get the handle for 'reviewer'.
 
328
 
 
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.
 
333
 
 
334
    :param reviewer: A launchpadlib `IPerson` object.
 
335
    :return: unicode text.
 
336
    """
 
337
    irc_handles = reviewer.irc_nicknames
 
338
    for handle in irc_handles:
 
339
        if handle.network == 'irc.freenode.net':
 
340
            return handle.nickname
 
341
    return reviewer.name
 
342
 
 
343
 
 
344
def _comma_separated_names(things):
 
345
    """Return a string of comma-separated names of 'things'.
 
346
 
 
347
    The list is sorted before being joined.
 
348
    """
 
349
    return ','.join(sorted(thing.name for thing in things))
 
350
 
 
351
 
 
352
def get_reviewer_clause(reviewers):
 
353
    """Get the reviewer section of a commit message, given the reviewers.
 
354
 
 
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]'.
 
358
    """
 
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('', []))
 
362
    ui_reviewers = []
 
363
    rc_reviewers = []
 
364
    for review_type, reviewer in reviewers.items():
 
365
        if review_type is None:
 
366
            continue
 
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.")
 
377
    if ui_reviewers:
 
378
        ui_clause = _comma_separated_names(ui_reviewers)
 
379
    else:
 
380
        ui_clause = 'none'
 
381
    if rc_reviewers:
 
382
        rc_clause = (
 
383
            '[release-critical=%s]' % _comma_separated_names(rc_reviewers))
 
384
    else:
 
385
        rc_clause = ''
 
386
    return '%s[r=%s][ui=%s]' % (
 
387
        rc_clause, _comma_separated_names(code_reviewers), ui_clause)