1#!/usr/bin/python
2
3"""Release testtools on Launchpad.
4
5Steps:
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
13Assumes that NEWS is in the parent directory, that the release sections are
14underlined with '~' and the subsections are underlined with '-'.
15
16Assumes that this file is in the 'scripts' directory a testtools tree that has
17already had a tarball built and uploaded with 'python setup.py sdist upload
18--sign'.
19"""
20
21from datetime import datetime, timedelta, tzinfo
22import logging
23import os
24import sys
25
26from launchpadlib.launchpad import Launchpad
27from launchpadlib import uris
28
29
30APP_NAME = 'testtools-lp-release'
31CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache')
32SERVICE_ROOT = uris.LPNET_SERVICE_ROOT
33
34FIX_COMMITTED = "Fix Committed"
35FIX_RELEASED = "Fix Released"
36
37# Launchpad file type for a tarball upload.
38CODE_RELEASE_TARBALL = 'Code Release Tarball'
39
40PROJECT_NAME = 'testtools'
41NEXT_MILESTONE_NAME = 'next'
42
43
44class _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
56UTC = _UTC()
57
58
59def 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
69LOG = configure_logging()
70
71
72def 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
79def 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("{}".format(task.title))
84        if task.milestone != next_milestone:
85            task.milestone = next_milestone
86            LOG.info("Re-assigning {}".format(task.title))
87            task.lp_save()
88
89
90def rename_milestone(next_milestone, new_name):
91    """Rename 'next_milestone' to 'new_name'."""
92    LOG.info("Renaming {} to {}".format(next_milestone.name, new_name))
93    next_milestone.name = new_name
94    next_milestone.lp_save()
95
96
97def 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 {}".format(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.startswith('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
146def release_milestone(milestone, release_notes, changelog):
147    date_released = datetime.now(tz=UTC)
148    LOG.info(
149        "Releasing milestone: {}, date {}".format(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
160def create_milestone(series, name):
161    """Create a new milestone in the same series as 'release_milestone'."""
162    LOG.info("Creating milestone {} in series {}".format(name, series.name))
163    return series.newMilestone(name=name)
164
165
166def close_fixed_bugs(milestone):
167    tasks = list(milestone.searchTasks())
168    for task in tasks:
169        LOG.debug("Found {}".format(task.title))
170        if task.status == FIX_COMMITTED:
171            LOG.info("Closing {}".format(task.title))
172            task.status = FIX_RELEASED
173        else:
174            LOG.warning(
175                "Bug not fixed, removing from milestone: {}".format(task.title))
176            task.milestone = None
177        task.lp_save()
178
179
180def 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: {}".format(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
196def 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 {} {}".format(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/{}-{}.tar.gz'.format(project_name, release_name))
206    if not os.path.isfile(tarball_path):
207        errors.append("{} does not exist".format(tarball_path))
208    if not os.path.isfile(tarball_path + '.asc'):
209        errors.append("{} does not exist".format(tarball_path + '.asc'))
210    if testtools.getMilestone(name=release_name):
211        errors.append("Milestone {} exists on {}".format(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
225def main(args):
226    launchpad = Launchpad.login_with(
227        APP_NAME, SERVICE_ROOT, CACHE_DIR, credentials_file='.lp_creds')
228    return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME)
229
230
231if __name__ == '__main__':
232    sys.exit(main(sys.argv))
233