Merge lp:~jml/launchpad/auto-land into lp:launchpad/db-devel

Proposed by Jonathan Lange
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
Reviewer Review Type Date Requested Status
Michael Hudson-Doyle Approve
Review via email: mp+12308@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Jonathan Lange (jml) wrote :

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

Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :
Download full text (18.9 KiB)

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/autoland.py'
> --- lib/devscripts/autoland.py 1970-01-01 00:00:00 +0000
> +++ lib/devscripts/autoland.py 2009-09-24 05:03:23 +0000
> @@ -0,0 +1,215 @@
> +"""Land an approved merge proposal."""
> +
> +import os
> +
> +from launchpadlib.launchpad import (
> + Launchpad, EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT)
> +from lazr.uri import URI
> +
> +DEV_SERVICE_ROOT = 'https://api.launchpad.dev/beta/'
> +LPNET_SERVICE_ROOT = 'https://api.launchpad.net/beta/'
> +
> +# 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 LaunchpadBranchLander:
> +
> + name = 'launchpad-branch-lander'
> + cache_dir = '~/.launchpadlib/cache'
> +
> + def __init__(self, launchpad):
> + self._launchpad = launchpad
> +
> + @classmethod
> + def load(cls, service_root=DEV_SERVICE_ROOT):
> + # XXX: No unit tests.
> + cache_dir = os.path.expanduser(cls.cache_dir)
> + # XXX: If cached data invalid, hard to delete & try again.
> + launchpad = Launchpad.login_with(cls.name, service_root, cache_dir)
> + return cls(launchpad)
> +
> + def load_merge_proposal(self, mp_url):
> + """Get the merge proposal object for the 'mp_url'."""
> + # XXX: No unit tests.
> + web_mp_uri = URI(mp_url)
> + api_mp_uri = self._launchpad._root_uri.append(
> + web_mp_uri.path.lstrip('/'))
> + return MergeProposal(self._launchpad.load(str(api_mp_uri)))
> +
> +
> +class MergeProposal:
> + """Wrapper around launchpadlib `IBranchMergeProposal` for landing."""
> +
> + def __init__(self, mp):
> + """Construct a merge proposal.
> +
> + :param mp: A launchpadlib `IBranchMergeProposal`.
> + """
> + self._mp = mp
> + self._launchpad = mp._root
> +
> + @property
> + def source_branch(self):
> + """The push URL of the source branch."""
> + return str(self._get_push_url(self._mp.source_branch))
> +
> + @property
> + def target_branch(self):
> + """The push URL of the target branch."""
> + return str(self._get_push_url(self._mp.source_branch))

These two implementations are identical, which seems unlikely to be
right.

> + def get_stakeholder_...

review: Approve
Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

I meant to say this originally...

review: Needs Fixing
Revision history for this message
Jonathan Lange (jml) wrote :
Download full text (21.0 KiB)

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/autoland.py'
>> --- lib/devscripts/autoland.py        1970-01-01 00:00:00 +0000
>> +++ lib/devscripts/autoland.py        2009-09-24 05:03:23 +0000
>> @@ -0,0 +1,215 @@
>> +"""Land an approved merge proposal."""
>> +
>> +import os
>> +
>> +from launchpadlib.launchpad import (
>> +    Launchpad, EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT)
>> +from lazr.uri import URI
>> +
>> +DEV_SERVICE_ROOT = 'https://api.launchpad.dev/beta/'
>> +LPNET_SERVICE_ROOT = 'https://api.launchpad.net/beta/'
>> +

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 LaunchpadBranchLander:
>> +
>> +    name = 'launchpad-branch-lander'
>> +    cache_dir = '~/.launchpadlib/cache'
>> +
>> +    def __init__(self, launchpad):
>> +        self._launchpad = launchpad
>> +
>> +    @classmethod
>> +    def load(cls, service_root=DEV_SERVICE_ROOT):
>> +        # XXX: No unit tests.

Made XXX compliant.

>> +        cache_dir = os.path.expanduser(cls.cache_dir)
>> +        # XXX: If cached data invalid, hard to delete & try again.

Made compliant, and filed bug 435813 about it.

>> +        launchpad = Launchpad.login_with(cls.name, service_root, cache_dir)
>> +        return cls(launchpad)
>> +
>> +    def load_merge_proposal(self, mp_url):
>> +        """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._root_uri.append(
>> +            web_mp_uri.path.lstrip('/'))
>> +        return MergeProposal(self._launchpad.load(str(api_mp_uri)))
>> +
>> +
>> +class MergeProposal:
>> +    """Wrapper around launchpadlib `IBranchMergeProposal` for landing."""
>> +
>> +    def __init__(self, mp):
>> +        """Construc...

Revision history for this message
Jonathan Lange (jml) wrote :
Download full text (7.9 KiB)

=== modified file 'lib/devscripts/autoland.py'
--- lib/devscripts/autoland.py 2009-09-24 10:43:40 +0000
+++ lib/devscripts/autoland.py 2009-09-24 11:23:10 +0000
@@ -12,10 +12,9 @@
 DEV_SERVICE_ROOT = 'https://api.launchpad.dev/beta/'
 LPNET_SERVICE_ROOT = 'https://api.launchpad.net/beta/'

-# 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 MissingReviewError(Exception):
+ """Raised when we try to get a review message without enough reviewers."""

 class LaunchpadBranchLander:
@@ -27,16 +26,17 @@
         self._launchpad = launchpad

     @classmethod
- def load(cls, service_root=DEV_SERVICE_ROOT):
- # XXX: No unit tests.
+ def load(cls, service_root=EDGE_SERVICE_ROOT):
+ # XXX: JonathanLange 2009-09-24: No unit tests.
         cache_dir = os.path.expanduser(cls.cache_dir)
- # 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.login_with(cls.name, service_root, cache_dir)
         return cls(launchpad)

     def load_merge_proposal(self, mp_url):
         """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._root_uri.append(
             web_mp_uri.path.lstrip('/'))
@@ -64,6 +64,16 @@
         """The push URL of the target branch."""
         return str(self._get_push_url(self._mp.target_branch))

+ @property
+ def commit_message(self):
+ """The commit message specified on the merge proposal."""
+ return self._mp.commit_message
+
+ @property
+ def is_approved(self):
+ """Is this merge proposal approved for landing."""
+ return self._mp.queue_status == 'Approved'
+
     def get_stakeholder_emails(self):
         """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(
             get_email,
             set([self._mp.source_branch.owner, self._launchpad.me]))
@@ -106,7 +116,7 @@

         :param branch: A launchpadlib `IBranch`.
         """
- # XXX: No unit tests.
+ # XXX: JonathanLange 2009-09-24: No unit tests.
         host = get_bazaar_host(str(self._launchpad._root_uri))
         # 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_separated_names(things):
+ """Return a string of comma-separated names of 'things'."""
+ return ','.join(thing.name for thing in things)
+
+
 def get_reviewer_clause(reviewers):
     """Get the reviewer section of a commit message, given the reviewers.

@@ -177,14 +192,21 @@
     code_reviewers = reviewers.get(None, [])
     c...

Read more...

Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :
Download full text (6.9 KiB)

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/autoland.py'
> --- lib/devscripts/autoland.py 1970-01-01 00:00:00 +0000
> +++ lib/devscripts/autoland.py 2009-09-25 04:11:41 +0000
> @@ -0,0 +1,226 @@
> +"""Land an approved merge proposal."""
> +
> +import os
> +
> +from launchpadlib.launchpad import (
> + Launchpad, EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT)
> +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://api.launchpad.dev/beta/'
> +LPNET_SERVICE_ROOT = 'https://api.launchpad.net/beta/'

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 `IBranchMergeProposal` for landing."""
> +
> + def __init__(self, mp):
> + """Construct a merge proposal.
> +
> + :param mp: A launchpadlib `IBranchMergeProposal`.
> + """
> + self._mp = mp
> + self._launchpad = mp._root
> +
> + @property
> + def source_branch(self):
> + """The push URL of the source branch."""
> + return str(self._get_push_url(self._mp.source_branch))
> +
> + @property
> + def target_branch(self):
> + """The push URL of the target branch."""
> + return self._mp.target_branch.unique_name.split('/')[-1]

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/ec2test/builtins.py'
> --- lib/devscripts/ec2test/builtins.py 2009-09-18 05:57:43 +0000
> +++ lib/devscripts/ec2test/builtins.py 2009-09-25 04:11:41 +0000
> @@ -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 LaunchpadBranchLander, MissingReviewError
> +
> from devscripts.ec2test.credentials import EC2Credentials
> from devscripts.ec2test.instance import (
> AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE, EC2Instance)
> @@ -263,6 +268,87 @@
> instance.set_up_and_run(postmortem, not headless, runner.run_tests)
>
>
> +class cmd_land(EC2Command):
> + """Land a merge proposal on Launchpad."""
> +
> + takes_options = [
> + machine_id_option,
> + instance_type_option,
> + postmortem_option,
> + debug_option,
> + Option('dry-run', help="Just print the equivalent ec2 test command."),
> + Option('print-commit', help="Print the ful...

Read more...

review: Approve
Revision history for this message
Jonathan Lange (jml) wrote :
Download full text (6.2 KiB)

> 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/autoland.py'
> > --- lib/devscripts/autoland.py 1970-01-01 00:00:00 +0000
> > +++ lib/devscripts/autoland.py 2009-09-25 04:11:41 +0000
> > @@ -0,0 +1,226 @@
> > +"""Land an approved merge proposal."""
> > +
> > +import os
> > +
> > +from launchpadlib.launchpad import (
> > + Launchpad, EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT)
> > +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://api.launchpad.dev/beta/'
> > +LPNET_SERVICE_ROOT = 'https://api.launchpad.net/beta/'
>
> 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 `IBranchMergeProposal` for landing."""
> > +
> > + def __init__(self, mp):
> > + """Construct a merge proposal.
> > +
> > + :param mp: A launchpadlib `IBranchMergeProposal`.
> > + """
> > + self._mp = mp
> > + self._launchpad = mp._root
> > +
> > + @property
> > + def source_branch(self):
> > + """The push URL of the source branch."""
> > + return str(self._get_push_url(self._mp.source_branch))
> > +
> > + @property
> > + def target_branch(self):
> > + """The push URL of the target branch."""
> > + return self._mp.target_branch.unique_name.split('/')[-1]
>
> 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/ec2test/builtins.py'
> > --- lib/devscripts/ec2test/builtins.py 2009-09-18 05:57:43 +0000
> > +++ lib/devscripts/ec2test/builtins.py 2009-09-25 04:11:41 +0000
> > @@ -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 LaunchpadBranchLander, MissingReviewError
> > +
> > from devscripts.ec2test.credentials import EC2Credentials
> > from devscripts.ec2test.instance import (
> > AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE, EC2Instance)
> > @@ -263,6 +268,87 @@
> > instance.set_up_and_run(postmortem, not headless, runner.run_tests)
> >
...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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):

Subscribers

People subscribed via source and target branches

to status/vote changes: