Don't try to release 'NEXT'.
[third_party/testtools] / scripts / _lp_release.py
1 #!/usr/bin/python
2
3 """Release testtools on Launchpad.
4
5 Steps:
6  1. Make sure all "Fix committed" bugs are assigned to 'next'
7  2. Rename 'next' to the new version
8  3. Release the milestone
9  4. Upload the tarball
10  5. Create a new 'next' milestone
11  6. Mark all "Fix committed" bugs in the milestone as "Fix released"
12
13 Assumes that NEWS is in the parent directory, that the release sections are
14 underlined with '~' and the subsections are underlined with '-'.
15
16 Assumes that this file is in the 'scripts' directory a testtools tree that has
17 already had a tarball built and uploaded with 'python setup.py sdist upload
18 --sign'.
19 """
20
21 from datetime import datetime, timedelta, tzinfo
22 import logging
23 import os
24 import sys
25
26 from launchpadlib.launchpad import Launchpad
27 from launchpadlib import uris
28
29
30 APP_NAME = 'testtools-lp-release'
31 CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache')
32 SERVICE_ROOT = uris.LPNET_SERVICE_ROOT
33
34 FIX_COMMITTED = u"Fix Committed"
35 FIX_RELEASED = u"Fix Released"
36
37 # Launchpad file type for a tarball upload.
38 CODE_RELEASE_TARBALL = 'Code Release Tarball'
39
40 PROJECT_NAME = 'testtools'
41 NEXT_MILESTONE_NAME = 'next'
42
43
44 class _UTC(tzinfo):
45     """UTC"""
46
47     def utcoffset(self, dt):
48         return timedelta(0)
49
50     def tzname(self, dt):
51         return "UTC"
52
53     def dst(self, dt):
54         return timedelta(0)
55
56 UTC = _UTC()
57
58
59 def configure_logging():
60     level = logging.INFO
61     log = logging.getLogger(APP_NAME)
62     log.setLevel(level)
63     handler = logging.StreamHandler()
64     handler.setLevel(level)
65     formatter = logging.Formatter("%(levelname)s: %(message)s")
66     handler.setFormatter(formatter)
67     log.addHandler(handler)
68     return log
69 LOG = configure_logging()
70
71
72 def get_path(relpath):
73     """Get the absolute path for something relative to this file."""
74     return os.path.abspath(
75         os.path.join(
76             os.path.dirname(os.path.dirname(__file__)), relpath))
77
78
79 def assign_fix_committed_to_next(testtools, next_milestone):
80     """Find all 'Fix Committed' and make sure they are in 'next'."""
81     fixed_bugs = list(testtools.searchTasks(status=FIX_COMMITTED))
82     for task in fixed_bugs:
83         LOG.debug("%s" % (task.title,))
84         if task.milestone != next_milestone:
85             task.milestone = next_milestone
86             LOG.info("Re-assigning %s" % (task.title,))
87             task.lp_save()
88
89
90 def rename_milestone(next_milestone, new_name):
91     """Rename 'next_milestone' to 'new_name'."""
92     LOG.info("Renaming %s to %s" % (next_milestone.name, new_name))
93     next_milestone.name = new_name
94     next_milestone.lp_save()
95
96
97 def get_release_notes_and_changelog(news_path):
98     release_notes = []
99     changelog = []
100     state = None
101     last_line = None
102
103     def is_heading_marker(line, marker_char):
104         return line and line == marker_char * len(line)
105
106     LOG.debug("Loading NEWS from %s" % (news_path,))
107     with open(news_path, 'r') as news:
108         for line in news:
109             line = line.strip()
110             if state is None:
111                 if (is_heading_marker(line, '~') and
112                     not last_line.startwith('NEXT')):
113                     milestone_name = last_line
114                     state = 'release-notes'
115                 else:
116                     last_line = line
117             elif state == 'title':
118                 # The line after the title is a heading marker line, so we
119                 # ignore it and change state. That which follows are the
120                 # release notes.
121                 state = 'release-notes'
122             elif state == 'release-notes':
123                 if is_heading_marker(line, '-'):
124                     state = 'changelog'
125                     # Last line in the release notes is actually the first
126                     # line of the changelog.
127                     changelog = [release_notes.pop(), line]
128                 else:
129                     release_notes.append(line)
130             elif state == 'changelog':
131                 if is_heading_marker(line, '~'):
132                     # Last line in changelog is actually the first line of the
133                     # next section.
134                     changelog.pop()
135                     break
136                 else:
137                     changelog.append(line)
138             else:
139                 raise ValueError("Couldn't parse NEWS")
140
141     release_notes = '\n'.join(release_notes).strip() + '\n'
142     changelog = '\n'.join(changelog).strip() + '\n'
143     return milestone_name, release_notes, changelog
144
145
146 def release_milestone(milestone, release_notes, changelog):
147     date_released = datetime.now(tz=UTC)
148     LOG.info(
149         "Releasing milestone: %s, date %s" % (milestone.name, date_released))
150     release = milestone.createProductRelease(
151         date_released=date_released,
152         changelog=changelog,
153         release_notes=release_notes,
154         )
155     milestone.is_active = False
156     milestone.lp_save()
157     return release
158
159
160 def create_milestone(series, name):
161     """Create a new milestone in the same series as 'release_milestone'."""
162     LOG.info("Creating milestone %s in series %s" % (name, series.name))
163     return series.newMilestone(name=name)
164
165
166 def close_fixed_bugs(milestone):
167     tasks = list(milestone.searchTasks())
168     for task in tasks:
169         LOG.debug("Found %s" % (task.title,))
170         if task.status == FIX_COMMITTED:
171             LOG.info("Closing %s" % (task.title,))
172             task.status = FIX_RELEASED
173         else:
174             LOG.warning(
175                 "Bug not fixed, removing from milestone: %s" % (task.title,))
176             task.milestone = None
177         task.lp_save()
178
179
180 def upload_tarball(release, tarball_path):
181     with open(tarball_path) as tarball:
182         tarball_content = tarball.read()
183     sig_path = tarball_path + '.asc'
184     with open(sig_path) as sig:
185         sig_content = sig.read()
186     tarball_name = os.path.basename(tarball_path)
187     LOG.info("Uploading tarball: %s" % (tarball_path,))
188     release.add_file(
189         file_type=CODE_RELEASE_TARBALL,
190         file_content=tarball_content, filename=tarball_name,
191         signature_content=sig_content,
192         signature_filename=sig_path,
193         content_type="application/x-gzip; charset=binary")
194
195
196 def release_project(launchpad, project_name, next_milestone_name):
197     testtools = launchpad.projects[project_name]
198     next_milestone = testtools.getMilestone(name=next_milestone_name)
199     release_name, release_notes, changelog = get_release_notes_and_changelog(
200         get_path('NEWS'))
201     LOG.info("Releasing %s %s" % (project_name, release_name))
202     # Since reversing these operations is hard, and inspecting errors from
203     # Launchpad is also difficult, do some looking before leaping.
204     errors = []
205     tarball_path = get_path('dist/%s-%s.tar.gz' % (project_name, release_name,))
206     if not os.path.isfile(tarball_path):
207         errors.append("%s does not exist" % (tarball_path,))
208     if not os.path.isfile(tarball_path + '.asc'):
209         errors.append("%s does not exist" % (tarball_path + '.asc',))
210     if testtools.getMilestone(name=release_name):
211         errors.append("Milestone %s exists on %s" % (release_name, project_name))
212     if errors:
213         for error in errors:
214             LOG.error(error)
215         return 1
216     assign_fix_committed_to_next(testtools, next_milestone)
217     rename_milestone(next_milestone, release_name)
218     release = release_milestone(next_milestone, release_notes, changelog)
219     upload_tarball(release, tarball_path)
220     create_milestone(next_milestone.series_target, next_milestone_name)
221     close_fixed_bugs(next_milestone)
222     return 0
223
224
225 def main(args):
226     launchpad = Launchpad.login_with(APP_NAME, SERVICE_ROOT, CACHE_DIR)
227     return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME)
228
229
230 if __name__ == '__main__':
231     sys.exit(main(sys.argv))