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

« back to all changes in this revision

Viewing changes to tarmac_land.py

  • Committer: James Henstridge
  • Date: 2007-12-17 14:51:19 UTC
  • Revision ID: james@jamesh.id.au-20071217145119-bxw361m02rkbk5w4
update Launchpad URL in setup.py

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)