Merge lp:~jml/launchpad/auto-land into lp:launchpad/db-devel
- auto-land
- Merge into db-devel
Status: | Merged |
---|---|
Merged at revision: | not available |
Proposed branch: | lp:~jml/launchpad/auto-land |
Merge into: | lp:launchpad/db-devel |
Diff against target: |
593 lines 6 files modified
lib/devscripts/__init__.py (+6/-0) lib/devscripts/autoland.py (+226/-0) lib/devscripts/ec2test/builtins.py (+88/-0) lib/devscripts/sourcecode.py (+2/-4) lib/devscripts/tests/test_autoland.py (+190/-0) lib/devscripts/tests/test_sourcecode.py (+2/-2) |
To merge this branch: | bzr merge lp:~jml/launchpad/auto-land |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Michael Hudson-Doyle | Approve | ||
Review via email: mp+12308@code.launchpad.net |
Commit message
Description of the change
Jonathan Lange (jml) wrote : | # |
Michael Hudson-Doyle (mwhudson) wrote : | # |
Well, I think this branch is an awesome idea. I can see that it's not
totally finished yet, but I think it's the sort of thing that even
half finished is useful. So if you can fix this so it actually does
something I think we should land it as soon as the tree opens. But it
should be at least do something first, I think :)
Apologies for the slight shallowness of this review, rollout related
brain frying in effect.
> === added file 'lib/devscripts
> --- lib/devscripts/
> +++ lib/devscripts/
> @@ -0,0 +1,215 @@
> +"""Land an approved merge proposal."""
> +
> +import os
> +
> +from launchpadlib.
> + Launchpad, EDGE_SERVICE_ROOT, STAGING_
> +from lazr.uri import URI
> +
> +DEV_SERVICE_ROOT = 'https:/
> +LPNET_SERVICE_ROOT = 'https:/
> +
> +# Given the merge proposal URL, get:
> +# - [DONE] the reviewer
> +# - [DONE] the UI reviewer
> +# - [DONE] the DB reviewer
> +# - the release-critical reviewer
> +# - [DONE] the commit message
> +# - [DONE] the branch
> +# - [DONE] the target branch
> +# - whether or not it has been approved
> +# - [DONE] the branch owner
I think the two not-DONE things are worth doing...
> +# If the review has not been approved, warn.
> +
> +# XXX: How do you do TDD of a launchpadlib program?
I certainly don't know.
> +# XXX: What's the right way to write docstrings for launchpadlib programs?
> +
> +
> +class LaunchpadBranch
> +
> + name = 'launchpad-
> + cache_dir = '~/.launchpadli
> +
> + def __init__(self, launchpad):
> + self._launchpad = launchpad
> +
> + @classmethod
> + def load(cls, service_
> + # XXX: No unit tests.
> + cache_dir = os.path.
> + # XXX: If cached data invalid, hard to delete & try again.
> + launchpad = Launchpad.
> + return cls(launchpad)
> +
> + def load_merge_
> + """Get the merge proposal object for the 'mp_url'."""
> + # XXX: No unit tests.
> + web_mp_uri = URI(mp_url)
> + api_mp_uri = self._launchpad
> + web_mp_
> + return MergeProposal(
> +
> +
> +class MergeProposal:
> + """Wrapper around launchpadlib `IBranchMergePr
> +
> + def __init__(self, mp):
> + """Construct a merge proposal.
> +
> + :param mp: A launchpadlib `IBranchMergePr
> + """
> + self._mp = mp
> + self._launchpad = mp._root
> +
> + @property
> + def source_
> + """The push URL of the source branch."""
> + return str(self.
> +
> + @property
> + def target_
> + """The push URL of the target branch."""
> + return str(self.
These two implementations are identical, which seems unlikely to be
right.
> + def get_stakeholder_...
Michael Hudson-Doyle (mwhudson) wrote : | # |
I meant to say this originally...
Jonathan Lange (jml) wrote : | # |
On Thu, Sep 24, 2009 at 6:57 AM, Michael Hudson
<email address hidden> wrote:
> Review: Approve
> Well, I think this branch is an awesome idea. I can see that it's not
> totally finished yet, but I think it's the sort of thing that even
> half finished is useful. So if you can fix this so it actually does
> something I think we should land it as soon as the tree opens. But it
> should be at least do something first, I think :)
>
Thanks :)
> Apologies for the slight shallowness of this review, rollout related
> brain frying in effect.
>
No worries.
>> === added file 'lib/devscripts
>> --- lib/devscripts/
>> +++ lib/devscripts/
>> @@ -0,0 +1,215 @@
>> +"""Land an approved merge proposal."""
>> +
>> +import os
>> +
>> +from launchpadlib.
>> + Launchpad, EDGE_SERVICE_ROOT, STAGING_
>> +from lazr.uri import URI
>> +
>> +DEV_SERVICE_ROOT = 'https:/
>> +LPNET_SERVICE_ROOT = 'https:/
>> +
Added a comment here explaining that these two variables are in recent
versions of launchpadlib.
>> +# Given the merge proposal URL, get:
>> +# - [DONE] the reviewer
>> +# - [DONE] the UI reviewer
>> +# - [DONE] the DB reviewer
>> +# - the release-critical reviewer
>> +# - [DONE] the commit message
>> +# - [DONE] the branch
>> +# - [DONE] the target branch
>> +# - whether or not it has been approved
>> +# - [DONE] the branch owner
>
> I think the two not-DONE things are worth doing...
>
I've removed all of these comments, added release-critical support and
approval checking.
>> +# If the review has not been approved, warn.
>> +
>> +# XXX: How do you do TDD of a launchpadlib program?
>
> I certainly don't know.
>
I've sent an email to launchpad-users about this and removed the comment.
>> +# XXX: What's the right way to write docstrings for launchpadlib programs?
>> +
>> +
Removed this comment.
>> +class LaunchpadBranch
>> +
>> + name = 'launchpad-
>> + cache_dir = '~/.launchpadli
>> +
>> + def __init__(self, launchpad):
>> + self._launchpad = launchpad
>> +
>> + @classmethod
>> + def load(cls, service_
>> + # XXX: No unit tests.
Made XXX compliant.
>> + cache_dir = os.path.
>> + # XXX: If cached data invalid, hard to delete & try again.
Made compliant, and filed bug 435813 about it.
>> + launchpad = Launchpad.
>> + return cls(launchpad)
>> +
>> + def load_merge_
>> + """Get the merge proposal object for the 'mp_url'."""
>> + # XXX: No unit tests.
Made compliant.
>> + web_mp_uri = URI(mp_url)
>> + api_mp_uri = self._launchpad
>> + web_mp_
>> + return MergeProposal(
>> +
>> +
>> +class MergeProposal:
>> + """Wrapper around launchpadlib `IBranchMergePr
>> +
>> + def __init__(self, mp):
>> + """Construc...
Jonathan Lange (jml) wrote : | # |
=== modified file 'lib/devscripts
--- lib/devscripts/
+++ lib/devscripts/
@@ -12,10 +12,9 @@
DEV_SERVICE_ROOT = 'https:/
LPNET_SERVICE_ROOT = 'https:/
-# Given the merge proposal URL, get:
-# - the release-critical reviewer
-# - whether or not it has been approved
-# If the review has not been approved, warn.
+
+class MissingReviewEr
+ """Raised when we try to get a review message without enough reviewers."""
class LaunchpadBranch
@@ -27,16 +26,17 @@
@classmethod
- def load(cls, service_
- # XXX: No unit tests.
+ def load(cls, service_
+ # XXX: JonathanLange 2009-09-24: No unit tests.
cache_dir = os.path.
- # XXX: If cached data invalid, hard to delete & try again.
+ # XXX: JonathanLange 2009-09-24 bug=435813: If cached data invalid,
+ # there's no easy way to delete it and try again.
launchpad = Launchpad.
return cls(launchpad)
def load_merge_
"""Get the merge proposal object for the 'mp_url'."""
- # XXX: No unit tests.
+ # XXX: JonathanLange 2009-09-24: No unit tests.
web_mp_uri = URI(mp_url)
api_mp_uri = self._launchpad
@@ -64,6 +64,16 @@
"""The push URL of the target branch."""
return str(self.
+ @property
+ def commit_
+ """The commit message specified on the merge proposal."""
+ return self._mp.
+
+ @property
+ def is_approved(self):
+ """Is this merge proposal approved for landing."""
+ return self._mp.
+
def get_stakeholder
"""Return a collection of people who should know about branch landing.
@@ -71,7 +81,7 @@
:return: A set of `IPerson`s.
"""
- # XXX: No unit tests.
+ # XXX: JonathanLange 2009-09-24: No unit tests.
return map(
@@ -106,7 +116,7 @@
:param branch: A launchpadlib `IBranch`.
"""
- # XXX: No unit tests.
+ # XXX: JonathanLange 2009-09-24: No unit tests.
host = get_bazaar_
# XXX: JonathanLange 2009-09-24 bug=435790: lazr.uri allows a path
# without a leading '/' and then doesn't insert a '/' in the final
@@ -167,6 +177,11 @@
return reviewer.name
+def _comma_
+ """Return a string of comma-separated names of 'things'."""
+ return ','.join(thing.name for thing in things)
+
+
def get_reviewer_
"""Get the reviewer section of a commit message, given the reviewers.
@@ -177,14 +192,21 @@
code_reviewers = reviewers.get(None, [])
c...
Michael Hudson-Doyle (mwhudson) wrote : | # |
One more time around the mulberry bush!
I'm just marginally on the side of not needing to see this again, but
feel free to send me another diff if any of the following is unclear
in what I'm asking for.
> === added file 'lib/devscripts
> --- lib/devscripts/
> +++ lib/devscripts/
> @@ -0,0 +1,226 @@
> +"""Land an approved merge proposal."""
> +
> +import os
> +
> +from launchpadlib.
> + Launchpad, EDGE_SERVICE_ROOT, STAGING_
> +from lazr.uri import URI
> +
> +# XXX: JonathanLange 2009-09-24: Both of these are available in more recent
> +# versions of launchpadlib. When we start using such versions, we should
> +# instead import these from launchpadlib.
> +DEV_SERVICE_ROOT = 'https:/
> +LPNET_SERVICE_ROOT = 'https:/
For tedious and boring reasons, when called from ec2 test this will
run with the system python's path, not the bin/py path. Does that
affect this comment? The bin/py path will be in effect when running
the tests though.
(In other news, Launchpad on 2.5 pls)
> +class MergeProposal:
> + """Wrapper around launchpadlib `IBranchMergePr
> +
> + def __init__(self, mp):
> + """Construct a merge proposal.
> +
> + :param mp: A launchpadlib `IBranchMergePr
> + """
> + self._mp = mp
> + self._launchpad = mp._root
> +
> + @property
> + def source_
> + """The push URL of the source branch."""
> + return str(self.
> +
> + @property
> + def target_
> + """The push URL of the target branch."""
> + return self._mp.
Um, the implementation still doesn't match my expectation of the
docstring -- isn't that going to just return the name of the target
name? (by name i mean ~foo/bar/baz --> baz).
> === modified file 'lib/devscripts
> --- lib/devscripts/
> +++ lib/devscripts/
> @@ -6,7 +6,9 @@
> __metaclass__ = type
> __all__ = []
>
> +import os
> import pdb
> +import subprocess
>
> from bzrlib.commands import Command
> from bzrlib.errors import BzrCommandError
> @@ -15,6 +17,9 @@
>
> import socket
>
> +from devscripts import get_launchpad_root
> +from devscripts.autoland import LaunchpadBranch
> +
> from devscripts.
> from devscripts.
> AVAILABLE_
> @@ -263,6 +268,87 @@
> instance.
>
>
> +class cmd_land(
> + """Land a merge proposal on Launchpad."""
> +
> + takes_options = [
> + machine_id_option,
> + instance_
> + postmortem_option,
> + debug_option,
> + Option('dry-run', help="Just print the equivalent ec2 test command."),
> + Option(
Jonathan Lange (jml) wrote : | # |
> One more time around the mulberry bush!
>
> I'm just marginally on the side of not needing to see this again, but
> feel free to send me another diff if any of the following is unclear
> in what I'm asking for.
>
Thanks.
> > === added file 'lib/devscripts
> > --- lib/devscripts/
> > +++ lib/devscripts/
> > @@ -0,0 +1,226 @@
> > +"""Land an approved merge proposal."""
> > +
> > +import os
> > +
> > +from launchpadlib.
> > + Launchpad, EDGE_SERVICE_ROOT, STAGING_
> > +from lazr.uri import URI
> > +
> > +# XXX: JonathanLange 2009-09-24: Both of these are available in more recent
> > +# versions of launchpadlib. When we start using such versions, we should
> > +# instead import these from launchpadlib.
> > +DEV_SERVICE_ROOT = 'https:/
> > +LPNET_SERVICE_ROOT = 'https:/
>
> For tedious and boring reasons, when called from ec2 test this will
> run with the system python's path, not the bin/py path. Does that
> affect this comment? The bin/py path will be in effect when running
> the tests though.
>
No, it's not affected.
> (In other news, Launchpad on 2.5 pls)
>
Thanks.
> > +class MergeProposal:
> > + """Wrapper around launchpadlib `IBranchMergePr
> > +
> > + def __init__(self, mp):
> > + """Construct a merge proposal.
> > +
> > + :param mp: A launchpadlib `IBranchMergePr
> > + """
> > + self._mp = mp
> > + self._launchpad = mp._root
> > +
> > + @property
> > + def source_
> > + """The push URL of the source branch."""
> > + return str(self.
> > +
> > + @property
> > + def target_
> > + """The push URL of the target branch."""
> > + return self._mp.
>
> Um, the implementation still doesn't match my expectation of the
> docstring -- isn't that going to just return the name of the target
> name? (by name i mean ~foo/bar/baz --> baz).
>
I've changed this to return the full URL. The call-site then does the split and [-1]. I've added comments along these lines.
> > === modified file 'lib/devscripts
> > --- lib/devscripts/
> > +++ lib/devscripts/
> > @@ -6,7 +6,9 @@
> > __metaclass__ = type
> > __all__ = []
> >
> > +import os
> > import pdb
> > +import subprocess
> >
> > from bzrlib.commands import Command
> > from bzrlib.errors import BzrCommandError
> > @@ -15,6 +17,9 @@
> >
> > import socket
> >
> > +from devscripts import get_launchpad_root
> > +from devscripts.autoland import LaunchpadBranch
> > +
> > from devscripts.
> > from devscripts.
> > AVAILABLE_
> > @@ -263,6 +268,87 @@
> > instance.
> >
...
Preview Diff
1 | === modified file 'lib/devscripts/__init__.py' |
2 | --- lib/devscripts/__init__.py 2009-09-09 03:27:47 +0000 |
3 | +++ lib/devscripts/__init__.py 2009-09-30 13:37:18 +0000 |
4 | @@ -2,3 +2,9 @@ |
5 | # GNU Affero General Public License version 3 (see the file LICENSE). |
6 | |
7 | """Scripts that are used in developing Launchpad.""" |
8 | + |
9 | +import os |
10 | + |
11 | + |
12 | +def get_launchpad_root(): |
13 | + return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) |
14 | |
15 | === added file 'lib/devscripts/autoland.py' |
16 | --- lib/devscripts/autoland.py 1970-01-01 00:00:00 +0000 |
17 | +++ lib/devscripts/autoland.py 2009-09-30 13:37:18 +0000 |
18 | @@ -0,0 +1,226 @@ |
19 | +"""Land an approved merge proposal.""" |
20 | + |
21 | +import os |
22 | + |
23 | +from launchpadlib.launchpad import ( |
24 | + Launchpad, EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT) |
25 | +from lazr.uri import URI |
26 | + |
27 | +# XXX: JonathanLange 2009-09-24: Both of these are available in more recent |
28 | +# versions of launchpadlib. When we start using such versions, we should |
29 | +# instead import these from launchpadlib. |
30 | +DEV_SERVICE_ROOT = 'https://api.launchpad.dev/beta/' |
31 | +LPNET_SERVICE_ROOT = 'https://api.launchpad.net/beta/' |
32 | + |
33 | + |
34 | +class MissingReviewError(Exception): |
35 | + """Raised when we try to get a review message without enough reviewers.""" |
36 | + |
37 | + |
38 | +class LaunchpadBranchLander: |
39 | + |
40 | + name = 'launchpad-branch-lander' |
41 | + cache_dir = '~/.launchpadlib/cache' |
42 | + |
43 | + def __init__(self, launchpad): |
44 | + self._launchpad = launchpad |
45 | + |
46 | + @classmethod |
47 | + def load(cls, service_root=EDGE_SERVICE_ROOT): |
48 | + # XXX: JonathanLange 2009-09-24: No unit tests. |
49 | + cache_dir = os.path.expanduser(cls.cache_dir) |
50 | + # XXX: JonathanLange 2009-09-24 bug=435813: If cached data invalid, |
51 | + # there's no easy way to delete it and try again. |
52 | + launchpad = Launchpad.login_with(cls.name, service_root, cache_dir) |
53 | + return cls(launchpad) |
54 | + |
55 | + def load_merge_proposal(self, mp_url): |
56 | + """Get the merge proposal object for the 'mp_url'.""" |
57 | + # XXX: JonathanLange 2009-09-24: No unit tests. |
58 | + web_mp_uri = URI(mp_url) |
59 | + api_mp_uri = self._launchpad._root_uri.append( |
60 | + web_mp_uri.path.lstrip('/')) |
61 | + return MergeProposal(self._launchpad.load(str(api_mp_uri))) |
62 | + |
63 | + |
64 | +class MergeProposal: |
65 | + """Wrapper around launchpadlib `IBranchMergeProposal` for landing.""" |
66 | + |
67 | + def __init__(self, mp): |
68 | + """Construct a merge proposal. |
69 | + |
70 | + :param mp: A launchpadlib `IBranchMergeProposal`. |
71 | + """ |
72 | + self._mp = mp |
73 | + self._launchpad = mp._root |
74 | + |
75 | + @property |
76 | + def source_branch(self): |
77 | + """The push URL of the source branch.""" |
78 | + return str(self._get_push_url(self._mp.source_branch)) |
79 | + |
80 | + @property |
81 | + def target_branch(self): |
82 | + """The push URL of the target branch.""" |
83 | + return str(self._get_push_url(self._mp.target_branch)) |
84 | + |
85 | + @property |
86 | + def commit_message(self): |
87 | + """The commit message specified on the merge proposal.""" |
88 | + return self._mp.commit_message |
89 | + |
90 | + @property |
91 | + def is_approved(self): |
92 | + """Is this merge proposal approved for landing.""" |
93 | + return self._mp.queue_status == 'Approved' |
94 | + |
95 | + def get_stakeholder_emails(self): |
96 | + """Return a collection of people who should know about branch landing. |
97 | + |
98 | + Used to determine who to email with the ec2 test results. |
99 | + |
100 | + :return: A set of `IPerson`s. |
101 | + """ |
102 | + # XXX: JonathanLange 2009-09-24: No unit tests. |
103 | + return set( |
104 | + map(get_email, [self._mp.source_branch.owner, self._launchpad.me])) |
105 | + |
106 | + def get_reviews(self): |
107 | + """Return a dictionary of all Approved reviews. |
108 | + |
109 | + Used to determine who has actually approved a branch for landing. The |
110 | + key of the dictionary is the type of review, and the value is the list |
111 | + of people who have voted Approve with that type. |
112 | + |
113 | + Common types include 'code', 'db', 'ui' and of course `None`. |
114 | + """ |
115 | + reviews = {} |
116 | + for vote in self._mp.votes: |
117 | + comment = vote.comment |
118 | + if comment is None or comment.vote != "Approve": |
119 | + continue |
120 | + reviewers = reviews.setdefault(vote.review_type, []) |
121 | + reviewers.append(vote.reviewer) |
122 | + return reviews |
123 | + |
124 | + def get_bugs(self): |
125 | + """Return a collection of bugs linked to the source branch.""" |
126 | + return self._mp.source_branch.linked_bugs |
127 | + |
128 | + def _get_push_url(self, branch): |
129 | + """Return the push URL for 'branch'. |
130 | + |
131 | + This function is a work-around for Launchpad's lack of exposing the |
132 | + branch's push URL. |
133 | + |
134 | + :param branch: A launchpadlib `IBranch`. |
135 | + """ |
136 | + # XXX: JonathanLange 2009-09-24: No unit tests. |
137 | + host = get_bazaar_host(str(self._launchpad._root_uri)) |
138 | + # XXX: JonathanLange 2009-09-24 bug=435790: lazr.uri allows a path |
139 | + # without a leading '/' and then doesn't insert a '/' in the final |
140 | + # URL. Do it ourselves. |
141 | + return URI(scheme='bzr+ssh', host=host, path='/' + branch.unique_name) |
142 | + |
143 | + def get_commit_message(self, commit_text, testfix=False): |
144 | + """Get the Launchpad-style commit message for a merge proposal.""" |
145 | + reviews = self.get_reviews() |
146 | + bugs = self.get_bugs() |
147 | + if testfix: |
148 | + testfix = '[testfix]' |
149 | + else: |
150 | + testfix = '' |
151 | + return '%s%s%s %s' % ( |
152 | + testfix, |
153 | + get_reviewer_clause(reviews), |
154 | + get_bugs_clause(bugs), |
155 | + commit_text) |
156 | + |
157 | + |
158 | +def get_email(person): |
159 | + """Get the preferred email address for 'person'.""" |
160 | + email_object = person.preferred_email_address |
161 | + # XXX: JonathanLange 2009-09-24 bug=319432: This raises a very obscure |
162 | + # error when the email address isn't set. e.g. with name12 in the sample |
163 | + # data. e.g. "httplib2.RelativeURIError: Only absolute URIs are allowed. |
164 | + # uri = tag:launchpad.net:2008:redacted". |
165 | + return email_object.email |
166 | + |
167 | + |
168 | +def get_bugs_clause(bugs): |
169 | + """Return the bugs clause of a commit message. |
170 | + |
171 | + :param bugs: A collection of `IBug` objects. |
172 | + :return: A string of the form "[bug=A,B,C]". |
173 | + """ |
174 | + if not bugs: |
175 | + return '' |
176 | + return '[bug=%s]' % ','.join(str(bug.id) for bug in bugs) |
177 | + |
178 | + |
179 | +def get_reviewer_handle(reviewer): |
180 | + """Get the handle for 'reviewer'. |
181 | + |
182 | + The handles of reviewers are included in the commit message for Launchpad |
183 | + changes. Historically, these handles have been the IRC nicks. Thus, if |
184 | + 'reviewer' has an IRC nickname for Freenode, we use that. Otherwise we use |
185 | + their Launchpad username. |
186 | + |
187 | + :param reviewer: A launchpadlib `IPerson` object. |
188 | + :return: unicode text. |
189 | + """ |
190 | + irc_handles = reviewer.irc_nicknames |
191 | + for handle in irc_handles: |
192 | + if handle.network == 'irc.freenode.net': |
193 | + return handle.nickname |
194 | + return reviewer.name |
195 | + |
196 | + |
197 | +def _comma_separated_names(things): |
198 | + """Return a string of comma-separated names of 'things'.""" |
199 | + return ','.join(thing.name for thing in things) |
200 | + |
201 | + |
202 | +def get_reviewer_clause(reviewers): |
203 | + """Get the reviewer section of a commit message, given the reviewers. |
204 | + |
205 | + :param reviewers: A dict mapping review types to lists of reviewers, as |
206 | + returned by 'get_reviews'. |
207 | + :return: A string like u'[r=foo,bar][ui=plop]'. |
208 | + """ |
209 | + code_reviewers = reviewers.get(None, []) |
210 | + code_reviewers.extend(reviewers.get('code', [])) |
211 | + code_reviewers.extend(reviewers.get('db', [])) |
212 | + if not code_reviewers: |
213 | + raise MissingReviewError("Need approved votes in order to land.") |
214 | + ui_reviewers = reviewers.get('ui', []) |
215 | + if ui_reviewers: |
216 | + ui_clause = _comma_separated_names(ui_reviewers) |
217 | + else: |
218 | + ui_clause = 'none' |
219 | + rc_reviewers = reviewers.get('release-critical', []) |
220 | + if rc_reviewers: |
221 | + rc_clause = ( |
222 | + '[release-critical=%s]' % _comma_separated_names(rc_reviewers)) |
223 | + else: |
224 | + rc_clause = '' |
225 | + return '%s[r=%s][ui=%s]' % ( |
226 | + rc_clause, _comma_separated_names(code_reviewers), ui_clause) |
227 | + |
228 | + |
229 | +def get_bazaar_host(api_root): |
230 | + """Get the Bazaar service for the given API root.""" |
231 | + # XXX: JonathanLange 2009-09-24 bug=435803: This is only needed because |
232 | + # Launchpad doesn't expose the push URL for branches. |
233 | + if api_root == EDGE_SERVICE_ROOT: |
234 | + return 'bazaar.launchpad.net' |
235 | + elif api_root == DEV_SERVICE_ROOT: |
236 | + return 'bazaar.launchpad.dev' |
237 | + elif api_root == STAGING_SERVICE_ROOT: |
238 | + return 'bazaar.staging.launchpad.net' |
239 | + elif api_root == LPNET_SERVICE_ROOT: |
240 | + return 'bazaar.launchpad.net' |
241 | + else: |
242 | + raise ValueError( |
243 | + 'Cannot determine Bazaar host. "%s" not a recognized Launchpad ' |
244 | + 'API root.' % (api_root,)) |
245 | |
246 | === modified file 'lib/devscripts/ec2test/builtins.py' |
247 | --- lib/devscripts/ec2test/builtins.py 2009-09-27 20:36:18 +0000 |
248 | +++ lib/devscripts/ec2test/builtins.py 2009-09-30 13:37:18 +0000 |
249 | @@ -6,7 +6,9 @@ |
250 | __metaclass__ = type |
251 | __all__ = [] |
252 | |
253 | +import os |
254 | import pdb |
255 | +import subprocess |
256 | |
257 | from bzrlib.commands import Command |
258 | from bzrlib.errors import BzrCommandError |
259 | @@ -15,6 +17,9 @@ |
260 | |
261 | import socket |
262 | |
263 | +from devscripts import get_launchpad_root |
264 | +from devscripts.autoland import LaunchpadBranchLander, MissingReviewError |
265 | + |
266 | from devscripts.ec2test.credentials import EC2Credentials |
267 | from devscripts.ec2test.instance import ( |
268 | AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE, EC2Instance) |
269 | @@ -264,6 +269,89 @@ |
270 | instance.set_up_and_run(postmortem, not headless, runner.run_tests) |
271 | |
272 | |
273 | +class cmd_land(EC2Command): |
274 | + """Land a merge proposal on Launchpad.""" |
275 | + |
276 | + takes_options = [ |
277 | + debug_option, |
278 | + Option('dry-run', help="Just print the equivalent ec2 test command."), |
279 | + Option('print-commit', help="Print the full commit message."), |
280 | + Option( |
281 | + 'testfix', |
282 | + help="Include the [testfix] prefix in the commit message."), |
283 | + Option( |
284 | + 'commit-text', short_name='s', type=str, |
285 | + help=( |
286 | + 'A description of the landing, not including reviewer ' |
287 | + 'metadata etc.')), |
288 | + Option( |
289 | + 'force', |
290 | + help="Land the branch even if the proposal is not approved."), |
291 | + ] |
292 | + |
293 | + takes_args = ['merge_proposal'] |
294 | + |
295 | + def _get_landing_command(self, source_url, target_url, commit_message, |
296 | + emails): |
297 | + """Return the command that would need to be run to submit with ec2.""" |
298 | + ec2_path = os.path.join(get_launchpad_root(), 'utilities', 'ec2') |
299 | + command = [ec2_path, 'test', '--headless'] |
300 | + command.extend(['--email=%s' % email for email in emails]) |
301 | + # 'ec2 test' has a bug where you cannot pass full URLs to branches to |
302 | + # the -b option. It has special logic for 'launchpad' branches, so we |
303 | + # piggy back on this to get 'devel' or 'db-devel'. |
304 | + target_branch_name = target_url.split('/')[-1] |
305 | + command.extend( |
306 | + ['-b', 'launchpad=%s' % (target_branch_name), '-s', |
307 | + commit_message, str(source_url)]) |
308 | + return command |
309 | + |
310 | + def run(self, merge_proposal, machine=None, |
311 | + instance_type=DEFAULT_INSTANCE_TYPE, postmortem=False, |
312 | + debug=False, commit_text=None, dry_run=False, testfix=False, |
313 | + print_commit=False, force=False): |
314 | + if debug: |
315 | + pdb.set_trace() |
316 | + if print_commit and dry_run: |
317 | + raise BzrCommandError( |
318 | + "Cannot specify --print-commit and --dry-run.") |
319 | + lander = LaunchpadBranchLander.load() |
320 | + mp = lander.load_merge_proposal(merge_proposal) |
321 | + if not mp.is_approved: |
322 | + if force: |
323 | + print "Merge proposal is not approved, landing anyway." |
324 | + else: |
325 | + raise BzrCommandError( |
326 | + "Merge proposal is not approved. Get it approved, or use " |
327 | + "--force to land it without approval.") |
328 | + if commit_text is None: |
329 | + commit_text = mp.commit_message |
330 | + if commit_text is None: |
331 | + raise BzrCommandError( |
332 | + "Commit text not specified. Use --commit-text, or specify a " |
333 | + "message on the merge proposal.") |
334 | + try: |
335 | + commit_message = mp.get_commit_message(commit_text, testfix) |
336 | + except MissingReviewError: |
337 | + raise BzrCommandError( |
338 | + "Cannot land branches that haven't got approved code " |
339 | + "reviews. Get an 'Approved' vote so we can fill in the " |
340 | + "[r=REVIEWER] section.") |
341 | + if print_commit: |
342 | + print commit_message |
343 | + return |
344 | + |
345 | + landing_command = self._get_landing_command( |
346 | + mp.source_branch, mp.target_branch, commit_message, |
347 | + mp.get_stakeholder_emails()) |
348 | + if dry_run: |
349 | + print landing_command |
350 | + else: |
351 | + # XXX: JonathanLange 2009-09-24 bug=439348: Call EC2 APIs |
352 | + # directly, rather than spawning a subprocess. |
353 | + return subprocess.call(landing_command) |
354 | + |
355 | + |
356 | class cmd_demo(EC2Command): |
357 | """Start a demo instance of Launchpad. |
358 | |
359 | |
360 | === modified file 'lib/devscripts/sourcecode.py' |
361 | --- lib/devscripts/sourcecode.py 2009-09-25 00:07:53 +0000 |
362 | +++ lib/devscripts/sourcecode.py 2009-09-30 13:37:18 +0000 |
363 | @@ -23,6 +23,8 @@ |
364 | from bzrlib.transport import get_transport |
365 | from bzrlib.workingtree import WorkingTree |
366 | |
367 | +from devscripts import get_launchpad_root |
368 | + |
369 | |
370 | def parse_config_file(file_handle): |
371 | """Parse the source code config file 'file_handle'. |
372 | @@ -182,10 +184,6 @@ |
373 | remove_branches(sourcecode_directory, removed) |
374 | |
375 | |
376 | -def get_launchpad_root(): |
377 | - return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) |
378 | - |
379 | - |
380 | # XXX: JonathanLange 2009-09-11: By default, the script will operate on the |
381 | # current checkout. Most people only have symlinks to sourcecode in their |
382 | # checkouts. This is fine for updating, but breaks for removing (you can't |
383 | |
384 | === added file 'lib/devscripts/tests/test_autoland.py' |
385 | --- lib/devscripts/tests/test_autoland.py 1970-01-01 00:00:00 +0000 |
386 | +++ lib/devscripts/tests/test_autoland.py 2009-09-30 13:37:18 +0000 |
387 | @@ -0,0 +1,190 @@ |
388 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
389 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
390 | + |
391 | +"""Tests for automatic landing thing.""" |
392 | + |
393 | +__metaclass__ = type |
394 | + |
395 | +import unittest |
396 | + |
397 | +from launchpadlib.launchpad import EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT |
398 | + |
399 | +from devscripts.autoland import ( |
400 | + get_bazaar_host, get_bugs_clause, get_reviewer_clause, |
401 | + get_reviewer_handle, MissingReviewError) |
402 | + |
403 | + |
404 | +class FakeBug: |
405 | + """Fake launchpadlib Bug object. |
406 | + |
407 | + Only used for the purposes of testing. |
408 | + """ |
409 | + |
410 | + def __init__(self, id): |
411 | + self.id = id |
412 | + |
413 | + |
414 | +class FakePerson: |
415 | + """Fake launchpadlib Person object. |
416 | + |
417 | + Only used for the purposes of testing. |
418 | + """ |
419 | + |
420 | + def __init__(self, name, irc_handles): |
421 | + self.name = name |
422 | + self.irc_nicknames = list(irc_handles) |
423 | + |
424 | + |
425 | +class FakeIRC: |
426 | + """Fake IRC handle. |
427 | + |
428 | + Only used for the purposes of testing. |
429 | + """ |
430 | + |
431 | + def __init__(self, nickname, network): |
432 | + self.nickname = nickname |
433 | + self.network = network |
434 | + |
435 | + |
436 | +class TestBugsClaused(unittest.TestCase): |
437 | + """Tests for `get_bugs_clause`.""" |
438 | + |
439 | + def test_no_bugs(self): |
440 | + # If there are no bugs, then there is no bugs clause. |
441 | + bugs_clause = get_bugs_clause([]) |
442 | + self.assertEqual('', bugs_clause) |
443 | + |
444 | + def test_one_bug(self): |
445 | + # If there's a bug, then the bugs clause is [bug=$ID]. |
446 | + bug = FakeBug(45) |
447 | + bugs_clause = get_bugs_clause([bug]) |
448 | + self.assertEqual('[bug=45]', bugs_clause) |
449 | + |
450 | + def test_two_bugs(self): |
451 | + # If there are two bugs, then the bugs clause is [bug=$ID,$ID]. |
452 | + bug1 = FakeBug(20) |
453 | + bug2 = FakeBug(45) |
454 | + bugs_clause = get_bugs_clause([bug1, bug2]) |
455 | + self.assertEqual('[bug=20,45]', bugs_clause) |
456 | + |
457 | + |
458 | +class TestGetReviewerHandle(unittest.TestCase): |
459 | + """Tests for `get_reviewer_handle`.""" |
460 | + |
461 | + def makePerson(self, name, irc_handles): |
462 | + return FakePerson(name, irc_handles) |
463 | + |
464 | + def test_no_irc_nicknames(self): |
465 | + # If the person has no IRC nicknames, their reviewer handle is their |
466 | + # Launchpad user name. |
467 | + person = self.makePerson(name='foo', irc_handles=[]) |
468 | + self.assertEqual('foo', get_reviewer_handle(person)) |
469 | + |
470 | + def test_freenode_irc_nick_preferred(self): |
471 | + # If the person has a Freenode IRC nickname, then that is preferred as |
472 | + # their user handle. |
473 | + person = self.makePerson( |
474 | + name='foo', irc_handles=[FakeIRC('bar', 'irc.freenode.net')]) |
475 | + self.assertEqual('bar', get_reviewer_handle(person)) |
476 | + |
477 | + def test_non_freenode_nicks_ignored(self): |
478 | + # If the person has IRC nicks that aren't freenode, we ignore them. |
479 | + person = self.makePerson( |
480 | + name='foo', irc_handles=[FakeIRC('bar', 'irc.efnet.net')]) |
481 | + self.assertEqual('foo', get_reviewer_handle(person)) |
482 | + |
483 | + |
484 | +class TestGetReviewerClause(unittest.TestCase): |
485 | + """Tests for `get_reviewer_clause`.""" |
486 | + |
487 | + def makePerson(self, name): |
488 | + return FakePerson(name, []) |
489 | + |
490 | + def get_reviewer_clause(self, reviewers): |
491 | + return get_reviewer_clause(reviewers) |
492 | + |
493 | + def test_one_reviewer_no_type(self): |
494 | + # It's very common for a merge proposal to be reviewed by one person |
495 | + # with no specified type of review. It such cases the review clause is |
496 | + # '[r=<person>][ui=none]'. |
497 | + clause = self.get_reviewer_clause({None: [self.makePerson('foo')]}) |
498 | + self.assertEqual('[r=foo][ui=none]', clause) |
499 | + |
500 | + def test_two_reviewers_no_type(self): |
501 | + # Branches can have more than one reviewer. |
502 | + clause = self.get_reviewer_clause( |
503 | + {None: [self.makePerson('foo'), self.makePerson('bar')]}) |
504 | + self.assertEqual('[r=foo,bar][ui=none]', clause) |
505 | + |
506 | + def test_code_reviewer_counts(self): |
507 | + # Some people explicitly specify the 'code' type when they do code |
508 | + # reviews, these are treated in the same way as reviewers without any |
509 | + # given type. |
510 | + clause = self.get_reviewer_clause({'code': [self.makePerson('foo')]}) |
511 | + self.assertEqual('[r=foo][ui=none]', clause) |
512 | + |
513 | + def test_release_critical(self): |
514 | + # Reviews that are marked as release-critical are included in a |
515 | + # separate clause. |
516 | + clause = self.get_reviewer_clause( |
517 | + {'code': [self.makePerson('foo')], |
518 | + 'release-critical': [self.makePerson('bar')]}) |
519 | + self.assertEqual('[release-critical=bar][r=foo][ui=none]', clause) |
520 | + |
521 | + def test_db_reviewer_counts(self): |
522 | + # There's no special way of annotating database reviews in Launchpad |
523 | + # commit messages, so they are included with the code reviews. |
524 | + clause = self.get_reviewer_clause({'db': [self.makePerson('foo')]}) |
525 | + self.assertEqual('[r=foo][ui=none]', clause) |
526 | + |
527 | + def test_ui_reviewers(self): |
528 | + # If someone has done a UI review, then that appears in the clause |
529 | + # separately from the code reviews. |
530 | + clause = self.get_reviewer_clause( |
531 | + {'code': [self.makePerson('foo')], |
532 | + 'ui': [self.makePerson('bar')], |
533 | + }) |
534 | + self.assertEqual('[r=foo][ui=bar]', clause) |
535 | + |
536 | + def test_no_reviewers(self): |
537 | + # If the merge proposal hasn't been approved by anyone, we cannot |
538 | + # generate a valid clause. |
539 | + self.assertRaises(MissingReviewError, self.get_reviewer_clause, {}) |
540 | + |
541 | + |
542 | +class TestGetBazaarHost(unittest.TestCase): |
543 | + """Tests for `get_bazaar_host`.""" |
544 | + |
545 | + def test_dev_service(self): |
546 | + # The Bazaar host for the dev service is bazaar.launchpad.dev. |
547 | + self.assertEqual( |
548 | + 'bazaar.launchpad.dev', |
549 | + get_bazaar_host('https://api.launchpad.dev/beta/')) |
550 | + |
551 | + def test_edge_service(self): |
552 | + # The Bazaar host for the edge service is bazaar.launchpad.net, since |
553 | + # there's no edge codehosting service. |
554 | + self.assertEqual( |
555 | + 'bazaar.launchpad.net', get_bazaar_host(EDGE_SERVICE_ROOT)) |
556 | + |
557 | + def test_production_service(self): |
558 | + # The Bazaar host for the production service is bazaar.launchpad.net. |
559 | + self.assertEqual( |
560 | + 'bazaar.launchpad.net', |
561 | + get_bazaar_host('https://api.launchpad.net/beta/')) |
562 | + |
563 | + def test_staging_service(self): |
564 | + # The Bazaar host for the staging service is |
565 | + # bazaar.staging.launchpad.net. |
566 | + self.assertEqual( |
567 | + 'bazaar.staging.launchpad.net', |
568 | + get_bazaar_host(STAGING_SERVICE_ROOT)) |
569 | + |
570 | + def test_unrecognized_service(self): |
571 | + # Any unrecognized URL will raise a ValueError. |
572 | + self.assertRaises( |
573 | + ValueError, get_bazaar_host, 'https://api.lunchpad.net') |
574 | + |
575 | + |
576 | +def test_suite(): |
577 | + return unittest.TestLoader().loadTestsFromName(__name__) |
578 | |
579 | === modified file 'lib/devscripts/tests/test_sourcecode.py' |
580 | --- lib/devscripts/tests/test_sourcecode.py 2009-09-25 00:07:53 +0000 |
581 | +++ lib/devscripts/tests/test_sourcecode.py 2009-09-30 13:37:18 +0000 |
582 | @@ -15,9 +15,9 @@ |
583 | from bzrlib.tests import TestCase |
584 | from bzrlib.transport import get_transport |
585 | |
586 | +from devscripts import get_launchpad_root |
587 | from devscripts.sourcecode import ( |
588 | - find_branches, get_launchpad_root, interpret_config, parse_config_file, |
589 | - plan_update) |
590 | + find_branches, interpret_config, parse_config_file, plan_update) |
591 | |
592 | |
593 | class TestParseConfigFile(unittest.TestCase): |
Hello Michael,
This branch adds a new ec2 command 'land' that will land a branch given only the merge proposal URL. My thinking is that 'ec2 test' is pretty complicated, and I can never remember the PQM rules, and I accidentally land branches on devel when I mean db-devel and all the information is on the merge proposal anyway.
This should also make it a lot easier to land branches for community contributors.
It's a bit experimental, and there are a few XXXs, but I think it needs a review to get unstuck.
Thanks,
jml