20afd0199ed732606e2e56e24e9afbb6bbf81b93
[nivanova/samba-autobuild/.git] / lib / 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, '~'):
112                     milestone_name = last_line
113                     state = 'release-notes'
114                 else:
115                     last_line = line
116             elif state == 'title':
117                 # The line after the title is a heading marker line, so we
118                 # ignore it and change state. That which follows are the
119                 # release notes.
120                 state = 'release-notes'
121             elif state == 'release-notes':
122                 if is_heading_marker(line, '-'):
123                     state = 'changelog'
124                     # Last line in the release notes is actually the first
125                     # line of the changelog.
126                     changelog = [release_notes.pop(), line]
127                 else:
128                     release_notes.append(line)
129             elif state == 'changelog':
130                 if is_heading_marker(line, '~'):
131                     # Last line in changelog is actually the first line of the
132                     # next section.
133                     changelog.pop()
134                     break
135                 else:
136                     changelog.append(line)
137             else:
138                 raise ValueError("Couldn't parse NEWS")
139
140     release_notes = '\n'.join(release_notes).strip() + '\n'
141     changelog = '\n'.join(changelog).strip() + '\n'
142     return milestone_name, release_notes, changelog
143
144
145 def release_milestone(milestone, release_notes, changelog):
146     date_released = datetime.now(tz=UTC)
147     LOG.info(
148         "Releasing milestone: %s, date %s" % (milestone.name, date_released))
149     release = milestone.createProductRelease(
150         date_released=date_released,
151         changelog=changelog,
152         release_notes=release_notes,
153         )
154     milestone.is_active = False
155     milestone.lp_save()
156     return release
157
158
159 def create_milestone(series, name):
160     """Create a new milestone in the same series as 'release_milestone'."""
161     LOG.info("Creating milestone %s in series %s" % (name, series.name))
162     return series.newMilestone(name=name)
163
164
165 def close_fixed_bugs(milestone):
166     tasks = list(milestone.searchTasks())
167     for task in tasks:
168         LOG.debug("Found %s" % (task.title,))
169         if task.status == FIX_COMMITTED:
170             LOG.info("Closing %s" % (task.title,))
171             task.status = FIX_RELEASED
172         else:
173             LOG.warning(
174                 "Bug not fixed, removing from milestone: %s" % (task.title,))
175             task.milestone = None
176         task.lp_save()
177
178
179 def upload_tarball(release, tarball_path):
180     with open(tarball_path) as tarball:
181         tarball_content = tarball.read()
182     sig_path = tarball_path + '.asc'
183     with open(sig_path) as sig:
184         sig_content = sig.read()
185     tarball_name = os.path.basename(tarball_path)
186     LOG.info("Uploading tarball: %s" % (tarball_path,))
187     release.add_file(
188         file_type=CODE_RELEASE_TARBALL,
189         file_content=tarball_content, filename=tarball_name,
190         signature_content=sig_content,
191         signature_filename=sig_path,
192         content_type="application/x-gzip; charset=binary")
193
194
195 def release_project(launchpad, project_name, next_milestone_name):
196     testtools = launchpad.projects[project_name]
197     next_milestone = testtools.getMilestone(name=next_milestone_name)
198     release_name, release_notes, changelog = get_release_notes_and_changelog(
199         get_path('NEWS'))
200     LOG.info("Releasing %s %s" % (project_name, release_name))
201     # Since reversing these operations is hard, and inspecting errors from
202     # Launchpad is also difficult, do some looking before leaping.
203     errors = []
204     tarball_path = get_path('dist/%s-%s.tar.gz' % (project_name, release_name,))
205     if not os.path.isfile(tarball_path):
206         errors.append("%s does not exist" % (tarball_path,))
207     if not os.path.isfile(tarball_path + '.asc'):
208         errors.append("%s does not exist" % (tarball_path + '.asc',))
209     if testtools.getMilestone(name=release_name):
210         errors.append("Milestone %s exists on %s" % (release_name, project_name))
211     if errors:
212         for error in errors:
213             LOG.error(error)
214         return 1
215     assign_fix_committed_to_next(testtools, next_milestone)
216     rename_milestone(next_milestone, release_name)
217     release = release_milestone(next_milestone, release_notes, changelog)
218     upload_tarball(release, tarball_path)
219     create_milestone(next_milestone.series_target, next_milestone_name)
220     close_fixed_bugs(next_milestone)
221     return 0
222
223
224 def main(args):
225     launchpad = Launchpad.login_with(APP_NAME, SERVICE_ROOT, CACHE_DIR)
226     return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME)
227
228
229 if __name__ == '__main__':
230     sys.exit(main(sys.argv))