~launchpad-pqm/launchpad/devel

9404.1.2 by Jonathan Lange
start hacking on autoland.
1
"""Land an approved merge proposal."""
2
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
3
from launchpadlib.launchpad import Launchpad
4
from launchpadlib.uris import (
5
    DEV_SERVICE_ROOT, EDGE_SERVICE_ROOT, LPNET_SERVICE_ROOT,
6
    STAGING_SERVICE_ROOT)
9404.1.2 by Jonathan Lange
start hacking on autoland.
7
from lazr.uri import URI
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
8
from bzrlib.errors import BzrCommandError
9
9404.1.2 by Jonathan Lange
start hacking on autoland.
10
9404.1.29 by Jonathan Lange
Handle the case when there are no reviewers.
11
class MissingReviewError(Exception):
12
    """Raised when we try to get a review message without enough reviewers."""
9404.1.2 by Jonathan Lange
start hacking on autoland.
13
14
11079.1.1 by Ursula Junque (Ursinha)
Added the no_qa parameter and how to deal with it in the get_commit_message method. Added an exception class to describe this error: MissingBugsError.
15
class MissingBugsError(Exception):
11079.1.15 by Ursula Junque (Ursinha)
changed all variables and options from incr to incremental, more self explainable; raising MissingBugs exceptions without a message
16
    """Merge proposal has no linked bugs and no [no-qa] tag."""
17
18
19
class MissingBugsIncrementalError(Exception):
20
    """Merge proposal has the [incr] tag but no linked bugs."""
11079.1.6 by Ursula Junque (Ursinha)
Adding MissingBugsIncrError exception and incr commit tag to the check_qa_clauses method, now changed to check both tags, no-qa and incr.
21
22
9404.1.2 by Jonathan Lange
start hacking on autoland.
23
class LaunchpadBranchLander:
24
25
    name = 'launchpad-branch-lander'
26
27
    def __init__(self, launchpad):
28
        self._launchpad = launchpad
29
30
    @classmethod
11677.3.3 by Robert Collins
More edge removal.
31
    def load(cls, service_root='production'):
9404.1.25 by Jonathan Lange
Compliant XXXs
32
        # XXX: JonathanLange 2009-09-24: No unit tests.
33
        # XXX: JonathanLange 2009-09-24 bug=435813: If cached data invalid,
34
        # there's no easy way to delete it and try again.
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
35
        launchpad = Launchpad.login_with(cls.name, service_root)
9404.1.2 by Jonathan Lange
start hacking on autoland.
36
        return cls(launchpad)
37
38
    def load_merge_proposal(self, mp_url):
39
        """Get the merge proposal object for the 'mp_url'."""
9404.1.25 by Jonathan Lange
Compliant XXXs
40
        # XXX: JonathanLange 2009-09-24: No unit tests.
9404.1.2 by Jonathan Lange
start hacking on autoland.
41
        web_mp_uri = URI(mp_url)
42
        api_mp_uri = self._launchpad._root_uri.append(
43
            web_mp_uri.path.lstrip('/'))
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
44
        return MergeProposal(self._launchpad.load(str(api_mp_uri)))
45
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
46
    def get_lp_branch(self, branch):
47
        """Get the launchpadlib branch based on a bzr branch."""
48
        # First try the public branch.
49
        branch_url = branch.get_public_branch()
50
        if branch_url:
51
            lp_branch = self._launchpad.branches.getByUrl(
52
                url=branch_url)
53
            if lp_branch is not None:
54
                return lp_branch
55
        # If that didn't work try the push location.
56
        branch_url = branch.get_push_location()
57
        if branch_url:
58
            lp_branch = self._launchpad.branches.getByUrl(
59
                url=branch_url)
60
            if lp_branch is not None:
61
                return lp_branch
62
        raise BzrCommandError(
63
            "No public branch could be found.  Please re-run and specify "
64
            "the URL for the merge proposal.")
65
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
66
    def get_merge_proposal_from_branch(self, branch):
67
        """Get the merge proposal from the branch."""
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
68
69
        lp_branch = self.get_lp_branch(branch)
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
70
        proposals = lp_branch.landing_targets
9644.4.2 by Brad Crittenden
Fixed error handling for the case when there are no merge proposals.
71
        if len(proposals) == 0:
72
            raise BzrCommandError(
73
                "The public branch has no source merge proposals.  "
74
                "You must have a merge proposal before attempting to "
75
                "land the branch.")
76
        elif len(proposals) > 1:
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
77
            raise BzrCommandError(
78
                "The public branch has multiple source merge proposals.  "
79
                "You must provide the URL to the one you wish to use.")
80
        return MergeProposal(proposals[0])
81
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
82
83
class MergeProposal:
9404.1.15 by Jonathan Lange
Docstrings.
84
    """Wrapper around launchpadlib `IBranchMergeProposal` for landing."""
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
85
86
    def __init__(self, mp):
9404.1.15 by Jonathan Lange
Docstrings.
87
        """Construct a merge proposal.
88
89
        :param mp: A launchpadlib `IBranchMergeProposal`.
90
        """
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
91
        self._mp = mp
92
        self._launchpad = mp._root
93
94
    @property
95
    def source_branch(self):
9404.1.15 by Jonathan Lange
Docstrings.
96
        """The push URL of the source branch."""
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
97
        return str(self._get_push_url(self._mp.source_branch))
98
99
    @property
100
    def target_branch(self):
9404.1.15 by Jonathan Lange
Docstrings.
101
        """The push URL of the target branch."""
9404.1.35 by Jonathan Lange
Make target_branch better.
102
        return str(self._get_push_url(self._mp.target_branch))
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
103
9404.1.27 by Jonathan Lange
Use EDGE, fix up commit message API.
104
    @property
105
    def commit_message(self):
106
        """The commit message specified on the merge proposal."""
107
        return self._mp.commit_message
108
9404.1.28 by Jonathan Lange
Check that the merge proposal is approved before landing it.
109
    @property
110
    def is_approved(self):
111
        """Is this merge proposal approved for landing."""
112
        return self._mp.queue_status == 'Approved'
113
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
114
    def get_stakeholder_emails(self):
115
        """Return a collection of people who should know about branch landing.
116
117
        Used to determine who to email with the ec2 test results.
118
119
        :return: A set of `IPerson`s.
120
        """
9404.1.25 by Jonathan Lange
Compliant XXXs
121
        # XXX: JonathanLange 2009-09-24: No unit tests.
9404.1.30 by Jonathan Lange
Don't specify duplicate email addresses.
122
        return set(
9644.4.5 by Brad Crittenden
Assume no review type is a code review.
123
            map(get_email,
124
                [self._mp.source_branch.owner, self._launchpad.me]))
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
125
126
    def get_reviews(self):
127
        """Return a dictionary of all Approved reviews.
128
129
        Used to determine who has actually approved a branch for landing. The
130
        key of the dictionary is the type of review, and the value is the list
131
        of people who have voted Approve with that type.
132
133
        Common types include 'code', 'db', 'ui' and of course `None`.
134
        """
135
        reviews = {}
136
        for vote in self._mp.votes:
137
            comment = vote.comment
138
            if comment is None or comment.vote != "Approve":
139
                continue
140
            reviewers = reviews.setdefault(vote.review_type, [])
141
            reviewers.append(vote.reviewer)
142
        return reviews
143
144
    def get_bugs(self):
9404.1.15 by Jonathan Lange
Docstrings.
145
        """Return a collection of bugs linked to the source branch."""
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
146
        return self._mp.source_branch.linked_bugs
147
148
    def _get_push_url(self, branch):
9404.1.9 by Jonathan Lange
Make get_push_url work and use it a little.
149
        """Return the push URL for 'branch'.
150
151
        This function is a work-around for Launchpad's lack of exposing the
152
        branch's push URL.
153
154
        :param branch: A launchpadlib `IBranch`.
155
        """
9404.1.25 by Jonathan Lange
Compliant XXXs
156
        # XXX: JonathanLange 2009-09-24: No unit tests.
9404.1.9 by Jonathan Lange
Make get_push_url work and use it a little.
157
        host = get_bazaar_host(str(self._launchpad._root_uri))
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
158
        # XXX: JonathanLange 2009-09-24 bug=435790: lazr.uri allows a path
159
        # without a leading '/' and then doesn't insert a '/' in the final
160
        # URL. Do it ourselves.
9404.1.9 by Jonathan Lange
Make get_push_url work and use it a little.
161
        return URI(scheme='bzr+ssh', host=host, path='/' + branch.unique_name)
9404.1.2 by Jonathan Lange
start hacking on autoland.
162
11869.5.2 by Diogo Matsubara
change get_commit_message to build_commit_message since that's what the method is actually doing.
163
    def build_commit_message(self, commit_text, testfix=False, no_qa=False,
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
164
                           incremental=False, rollback=None):
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
165
        """Get the Launchpad-style commit message for a merge proposal."""
166
        reviews = self.get_reviews()
167
        bugs = self.get_bugs()
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
168
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
169
        tags = ''.join([
170
            get_testfix_clause(testfix),
171
            get_reviewer_clause(reviews),
172
            get_bugs_clause(bugs),
173
            get_qa_clause(bugs, no_qa,
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
174
                incremental, rollback=rollback),
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
175
            ])
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
176
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
177
        return '%s %s' % (tags, commit_text)
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
178
11869.5.1 by Diogo Matsubara
set the commit message in the merge proposal from the built commit message.
179
    def set_commit_message(self, commit_message):
180
        """Set the Launchpad-style commit message for a merge proposal."""
181
        self._mp.commit_message = commit_message
182
        self._mp.lp_save()
183
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
184
185
def get_testfix_clause(testfix=False):
186
    """Get the testfix clause."""
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
187
    if testfix:
188
        testfix_clause = '[testfix]'
189
    else:
190
        testfix_clause = ''
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
191
    return testfix_clause
192
193
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
194
def get_qa_clause(bugs, no_qa=False, incremental=False, rollback=None):
11079.1.15 by Ursula Junque (Ursinha)
changed all variables and options from incr to incremental, more self explainable; raising MissingBugs exceptions without a message
195
    """Check the no-qa and incremental options, getting the qa clause.
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
196
11490.1.1 by Diogo Matsubara
implement the no-qa and incr use case in the autoland.py script
197
    The qa clause will always be or no-qa, or incremental, or no-qa and
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
198
    incremental, or a revno for the rollback clause, or no tags.
199
200
    See https://dev.launchpad.net/QAProcessContinuousRollouts for detailed
201
    explanation of each clause.
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
202
    """
203
    qa_clause = ""
11079.1.6 by Ursula Junque (Ursinha)
Adding MissingBugsIncrError exception and incr commit tag to the check_qa_clauses method, now changed to check both tags, no-qa and incr.
204
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
205
    if not bugs and not no_qa and not incremental and not rollback:
11079.1.15 by Ursula Junque (Ursinha)
changed all variables and options from incr to incremental, more self explainable; raising MissingBugs exceptions without a message
206
        raise MissingBugsError
207
208
    if incremental and not bugs:
209
        raise MissingBugsIncrementalError
210
11490.1.1 by Diogo Matsubara
implement the no-qa and incr use case in the autoland.py script
211
    if no_qa and incremental:
212
        qa_clause = '[no-qa][incr]'
213
    elif incremental:
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
214
        qa_clause = '[incr]'
215
    elif no_qa:
216
        qa_clause = '[no-qa]'
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
217
    elif rollback:
218
        qa_clause = '[rollback=%d]' % rollback
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
219
    else:
220
        qa_clause = ''
11079.1.6 by Ursula Junque (Ursinha)
Adding MissingBugsIncrError exception and incr commit tag to the check_qa_clauses method, now changed to check both tags, no-qa and incr.
221
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
222
    return qa_clause
11079.1.3 by Ursula Junque (Ursinha)
created a separated method to take care of the no-qa tag, check_qa_clause
223
224
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
225
def get_email(person):
9404.1.17 by Jonathan Lange
More docstrings.
226
    """Get the preferred email address for 'person'."""
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
227
    email_object = person.preferred_email_address
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
228
    # XXX: JonathanLange 2009-09-24 bug=319432: This raises a very obscure
229
    # error when the email address isn't set. e.g. with name12 in the sample
230
    # data. e.g. "httplib2.RelativeURIError: Only absolute URIs are allowed.
231
    # uri = tag:launchpad.net:2008:redacted".
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
232
    return email_object.email
233
9404.1.2 by Jonathan Lange
start hacking on autoland.
234
235
def get_bugs_clause(bugs):
9404.1.4 by Jonathan Lange
More tests.
236
    """Return the bugs clause of a commit message.
237
238
    :param bugs: A collection of `IBug` objects.
239
    :return: A string of the form "[bug=A,B,C]".
240
    """
9404.1.2 by Jonathan Lange
start hacking on autoland.
241
    if not bugs:
242
        return ''
243
    return '[bug=%s]' % ','.join(str(bug.id) for bug in bugs)
244
245
9404.1.4 by Jonathan Lange
More tests.
246
def get_reviewer_handle(reviewer):
247
    """Get the handle for 'reviewer'.
248
249
    The handles of reviewers are included in the commit message for Launchpad
250
    changes. Historically, these handles have been the IRC nicks. Thus, if
251
    'reviewer' has an IRC nickname for Freenode, we use that. Otherwise we use
252
    their Launchpad username.
253
254
    :param reviewer: A launchpadlib `IPerson` object.
9404.1.5 by Jonathan Lange
Get the reviewer string.
255
    :return: unicode text.
9404.1.4 by Jonathan Lange
More tests.
256
    """
257
    irc_handles = reviewer.irc_nicknames
258
    for handle in irc_handles:
259
        if handle.network == 'irc.freenode.net':
260
            return handle.nickname
261
    return reviewer.name
262
263
9404.1.26 by Jonathan Lange
Release-critical support added.
264
def _comma_separated_names(things):
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
265
    """Return a string of comma-separated names of 'things'.
266
267
    The list is sorted before being joined.
268
    """
269
    return ','.join(sorted(thing.name for thing in things))
9404.1.26 by Jonathan Lange
Release-critical support added.
270
271
9404.1.5 by Jonathan Lange
Get the reviewer string.
272
def get_reviewer_clause(reviewers):
273
    """Get the reviewer section of a commit message, given the reviewers.
274
275
    :param reviewers: A dict mapping review types to lists of reviewers, as
276
        returned by 'get_reviews'.
277
    :return: A string like u'[r=foo,bar][ui=plop]'.
278
    """
9862.1.2 by Aaron Bentley
Handle the case where the reviewer field is ''.
279
    # If no review type is specified it is assumed to be a code review.
9644.4.5 by Brad Crittenden
Assume no review type is a code review.
280
    code_reviewers = reviewers.get(None, [])
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
281
    ui_reviewers = []
282
    rc_reviewers = []
283
    for review_type, reviewer in reviewers.items():
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
284
        if review_type is None:
285
            continue
9862.1.2 by Aaron Bentley
Handle the case where the reviewer field is ''.
286
        if review_type == '':
287
            code_reviewers.extend(reviewer)
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
288
        if 'code' in review_type or 'db' in review_type:
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
289
            code_reviewers.extend(reviewer)
290
        if 'ui' in review_type:
291
            ui_reviewers.extend(reviewer)
292
        if 'release-critical' in review_type:
293
            rc_reviewers.extend(reviewer)
9404.1.29 by Jonathan Lange
Handle the case when there are no reviewers.
294
    if not code_reviewers:
295
        raise MissingReviewError("Need approved votes in order to land.")
9404.1.5 by Jonathan Lange
Get the reviewer string.
296
    if ui_reviewers:
9404.1.26 by Jonathan Lange
Release-critical support added.
297
        ui_clause = _comma_separated_names(ui_reviewers)
9404.1.5 by Jonathan Lange
Get the reviewer string.
298
    else:
299
        ui_clause = 'none'
9404.1.26 by Jonathan Lange
Release-critical support added.
300
    if rc_reviewers:
301
        rc_clause = (
302
            '[release-critical=%s]' % _comma_separated_names(rc_reviewers))
303
    else:
304
        rc_clause = ''
305
    return '%s[r=%s][ui=%s]' % (
306
        rc_clause, _comma_separated_names(code_reviewers), ui_clause)
9404.1.5 by Jonathan Lange
Get the reviewer string.
307
308
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
309
def get_bazaar_host(api_root):
310
    """Get the Bazaar service for the given API root."""
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
311
    # XXX: JonathanLange 2009-09-24 bug=435803: This is only needed because
312
    # Launchpad doesn't expose the push URL for branches.
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
313
    if api_root.startswith(EDGE_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
314
        return 'bazaar.launchpad.net'
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
315
    elif api_root.startswith(DEV_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
316
        return 'bazaar.launchpad.dev'
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
317
    elif api_root.startswith(STAGING_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
318
        return 'bazaar.staging.launchpad.net'
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
319
    elif api_root.startswith(LPNET_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
320
        return 'bazaar.launchpad.net'
321
    else:
322
        raise ValueError(
323
            'Cannot determine Bazaar host. "%s" not a recognized Launchpad '
324
            'API root.' % (api_root,))