~launchpad-pqm/launchpad/devel

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