~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.
12143.3.2 by Leonard Richardson
Updated to support the new Launchpad constructor and the return of login_with.
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
11869.5.3 by Diogo Matsubara
add code to remove duplicate tags from commit message
169
        tags = [
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
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),
11869.5.3 by Diogo Matsubara
add code to remove duplicate tags from commit message
175
            ]
176
177
        # Make sure we don't add duplicated tags to commit_text.
178
        commit_tags = tags[:]
179
        for tag in tags:
180
            if tag in commit_text:
181
                commit_tags.remove(tag)
182
183
        if commit_tags:
184
            return '%s %s' % (''.join(commit_tags), commit_text)
185
        else:
186
            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.
187
11869.5.1 by Diogo Matsubara
set the commit message in the merge proposal from the built commit message.
188
    def set_commit_message(self, commit_message):
189
        """Set the Launchpad-style commit message for a merge proposal."""
190
        self._mp.commit_message = commit_message
191
        self._mp.lp_save()
192
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.
193
194
def get_testfix_clause(testfix=False):
195
    """Get the testfix clause."""
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
196
    if testfix:
197
        testfix_clause = '[testfix]'
198
    else:
199
        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.
200
    return testfix_clause
201
202
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
203
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
204
    """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.
205
11490.1.1 by Diogo Matsubara
implement the no-qa and incr use case in the autoland.py script
206
    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
207
    incremental, or a revno for the rollback clause, or no tags.
208
209
    See https://dev.launchpad.net/QAProcessContinuousRollouts for detailed
210
    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.
211
    """
212
    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.
213
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
214
    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
215
        raise MissingBugsError
216
217
    if incremental and not bugs:
218
        raise MissingBugsIncrementalError
219
11490.1.1 by Diogo Matsubara
implement the no-qa and incr use case in the autoland.py script
220
    if no_qa and incremental:
221
        qa_clause = '[no-qa][incr]'
222
    elif incremental:
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
223
        qa_clause = '[incr]'
224
    elif no_qa:
225
        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
226
    elif rollback:
227
        qa_clause = '[rollback=%d]' % rollback
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
228
    else:
229
        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.
230
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.
231
    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
232
233
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
234
def get_email(person):
9404.1.17 by Jonathan Lange
More docstrings.
235
    """Get the preferred email address for 'person'."""
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
236
    email_object = person.preferred_email_address
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
237
    # XXX: JonathanLange 2009-09-24 bug=319432: This raises a very obscure
238
    # error when the email address isn't set. e.g. with name12 in the sample
239
    # data. e.g. "httplib2.RelativeURIError: Only absolute URIs are allowed.
240
    # uri = tag:launchpad.net:2008:redacted".
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
241
    return email_object.email
242
9404.1.2 by Jonathan Lange
start hacking on autoland.
243
244
def get_bugs_clause(bugs):
9404.1.4 by Jonathan Lange
More tests.
245
    """Return the bugs clause of a commit message.
246
247
    :param bugs: A collection of `IBug` objects.
248
    :return: A string of the form "[bug=A,B,C]".
249
    """
9404.1.2 by Jonathan Lange
start hacking on autoland.
250
    if not bugs:
251
        return ''
252
    return '[bug=%s]' % ','.join(str(bug.id) for bug in bugs)
253
254
9404.1.4 by Jonathan Lange
More tests.
255
def get_reviewer_handle(reviewer):
256
    """Get the handle for 'reviewer'.
257
258
    The handles of reviewers are included in the commit message for Launchpad
259
    changes. Historically, these handles have been the IRC nicks. Thus, if
260
    'reviewer' has an IRC nickname for Freenode, we use that. Otherwise we use
261
    their Launchpad username.
262
263
    :param reviewer: A launchpadlib `IPerson` object.
9404.1.5 by Jonathan Lange
Get the reviewer string.
264
    :return: unicode text.
9404.1.4 by Jonathan Lange
More tests.
265
    """
266
    irc_handles = reviewer.irc_nicknames
267
    for handle in irc_handles:
268
        if handle.network == 'irc.freenode.net':
269
            return handle.nickname
270
    return reviewer.name
271
272
9404.1.26 by Jonathan Lange
Release-critical support added.
273
def _comma_separated_names(things):
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
274
    """Return a string of comma-separated names of 'things'.
275
276
    The list is sorted before being joined.
277
    """
278
    return ','.join(sorted(thing.name for thing in things))
9404.1.26 by Jonathan Lange
Release-critical support added.
279
280
9404.1.5 by Jonathan Lange
Get the reviewer string.
281
def get_reviewer_clause(reviewers):
282
    """Get the reviewer section of a commit message, given the reviewers.
283
284
    :param reviewers: A dict mapping review types to lists of reviewers, as
285
        returned by 'get_reviews'.
286
    :return: A string like u'[r=foo,bar][ui=plop]'.
287
    """
9862.1.2 by Aaron Bentley
Handle the case where the reviewer field is ''.
288
    # 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.
289
    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.
290
    ui_reviewers = []
291
    rc_reviewers = []
292
    for review_type, reviewer in reviewers.items():
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
293
        if review_type is None:
294
            continue
9862.1.2 by Aaron Bentley
Handle the case where the reviewer field is ''.
295
        if review_type == '':
296
            code_reviewers.extend(reviewer)
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
297
        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.
298
            code_reviewers.extend(reviewer)
299
        if 'ui' in review_type:
300
            ui_reviewers.extend(reviewer)
301
        if 'release-critical' in review_type:
302
            rc_reviewers.extend(reviewer)
9404.1.29 by Jonathan Lange
Handle the case when there are no reviewers.
303
    if not code_reviewers:
304
        raise MissingReviewError("Need approved votes in order to land.")
9404.1.5 by Jonathan Lange
Get the reviewer string.
305
    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.
306
        ui_clause = '[ui=%s]' % _comma_separated_names(ui_reviewers)
9404.1.5 by Jonathan Lange
Get the reviewer string.
307
    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.
308
        ui_clause = ''
9404.1.26 by Jonathan Lange
Release-critical support added.
309
    if rc_reviewers:
310
        rc_clause = (
311
            '[release-critical=%s]' % _comma_separated_names(rc_reviewers))
312
    else:
313
        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.
314
    return '%s[r=%s]%s' % (
9404.1.26 by Jonathan Lange
Release-critical support added.
315
        rc_clause, _comma_separated_names(code_reviewers), ui_clause)
9404.1.5 by Jonathan Lange
Get the reviewer string.
316
317
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
318
def get_bazaar_host(api_root):
319
    """Get the Bazaar service for the given API root."""
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
320
    # XXX: JonathanLange 2009-09-24 bug=435803: This is only needed because
321
    # 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.
322
    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.
323
        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.
324
    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.
325
        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.
326
    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.
327
        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.
328
    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.
329
        return 'bazaar.launchpad.net'
330
    else:
331
        raise ValueError(
332
            'Cannot determine Bazaar host. "%s" not a recognized Launchpad '
333
            'API root.' % (api_root,))