1#!/usr/bin/env python
2
3from collections import namedtuple
4
5import argparse
6import base64
7import copy
8import json
9import subprocess
10import sys
11import urllib.parse
12import urllib.request
13import urllib.error
14
15class Error(Exception):
16    pass
17
18class Version(object):
19    def __init__(self, version):
20        versions = version.split(sep='.')
21        if len(versions) < 2 or len(versions) > 3:
22            raise Error("Invalid version string '{}'".format(version))
23        self.major = int(versions[0])
24        self.minor = int(versions[1])
25        self.revision = int(versions[2]) if len(versions) == 3 else 0
26
27    def __str__(self):
28        return '{}.{}.{}'.format(self.major, self.minor, self.revision)
29
30    def __eq__(self, other):
31        return self.major == other.major and self.minor == other.minor and self.revision == other.revision
32
33def verify_version(version):
34    expected = {
35        'VERSION':      [ '"{}"'.format(version), None ],
36        'VER_MAJOR':    [ str(version.major), None ],
37        'VER_MINOR':    [ str(version.minor), None ],
38        'VER_REVISION': [ str(version.revision), None ],
39        'VER_PATCH':    [ '0', None ],
40        'SOVERSION':    [ '"{}.{}"'.format(version.major, version.minor), None ],
41    }
42
43    # Parse CMakeLists
44    with open('CMakeLists.txt') as f:
45        for line in f.readlines():
46            if line.startswith('project(libgit2 VERSION "{}"'.format(version)):
47                break
48        else:
49            raise Error("cmake: invalid project definition")
50
51    # Parse version.h
52    with open('include/git2/version.h') as f:
53        lines = f.readlines()
54
55    for key in expected.keys():
56        define = '#define LIBGIT2_{} '.format(key)
57        for line in lines:
58            if line.startswith(define):
59                expected[key][1] = line[len(define):].strip()
60                break
61        else:
62            raise Error("version.h: missing define for '{}'".format(key))
63
64    for k, v in expected.items():
65        if v[0] != v[1]:
66            raise Error("version.h: define '{}' does not match (got '{}', expected '{}')".format(k, v[0], v[1]))
67
68    with open('package.json') as f:
69        pkg = json.load(f)
70
71    try:
72        pkg_version = Version(pkg["version"])
73    except KeyError as err:
74        raise Error("package.json: missing the field {}".format(err))
75
76    if pkg_version != version:
77        raise Error("package.json: version does not match (got '{}', expected '{}')".format(pkg_version, version))
78
79def generate_relnotes(tree, version):
80    with open('docs/changelog.md') as f:
81        lines = f.readlines()
82
83    if not lines[0].startswith('v'):
84        raise Error("changelog.md: missing section for v{}".format(version))
85    try:
86        v = Version(lines[0][1:].strip())
87    except:
88        raise Error("changelog.md: invalid version string {}".format(lines[0].strip()))
89    if v != version:
90        raise Error("changelog.md: changelog version doesn't match (got {}, expected {})".format(v, version))
91    if not lines[1].startswith('----'):
92        raise Error("changelog.md: missing version header")
93    if lines[2] != '\n':
94        raise Error("changelog.md: missing newline after version header")
95
96    for i, line in enumerate(lines[3:]):
97        if not line.startswith('v'):
98            continue
99        try:
100            Version(line[1:].strip())
101            break
102        except:
103            continue
104    else:
105        raise Error("changelog.md: cannot find section header of preceding release")
106
107    return ''.join(lines[3:i + 3]).strip()
108
109def git(*args):
110    process = subprocess.run([ 'git', *args ], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
111    if process.returncode != 0:
112        raise Error('Failed executing git {}: {}'.format(' '.join(args),  process.stderr.decode()))
113    return process.stdout
114
115def post(url, data, contenttype, user, password):
116    request = urllib.request.Request(url, data=data)
117    request.add_header('Accept', 'application/json')
118    request.add_header('Content-Type', contenttype)
119    request.add_header('Content-Length', len(data))
120    request.add_header('Authorization', 'Basic ' + base64.b64encode('{}:{}'.format(user, password).encode()).decode())
121
122    try:
123        response = urllib.request.urlopen(request)
124        if response.getcode() != 201:
125            raise Error("POST to '{}' failed: {}".format(url, response.reason))
126    except urllib.error.URLError as e:
127        raise Error("POST to '{}' failed: {}".format(url, e))
128    data = json.load(response)
129
130    return data
131
132def generate_asset(version, tree, archive_format):
133    Asset = namedtuple('Asset', ['name', 'label', 'mimetype', 'data'])
134    mimetype = 'application/{}'.format('gzip' if archive_format == 'tar.gz' else 'zip')
135    return Asset(
136        "libgit2-{}.{}".format(version, archive_format), "Release sources: libgit2-{}.{}".format(version, archive_format), mimetype,
137        git('archive', '--format', archive_format, '--prefix', 'libgit2-{}/'.format(version), tree)
138    )
139
140def release(args):
141    params = {
142        "tag_name": 'v' + str(args.version),
143        "name": 'libgit2 v' + str(args.version),
144        "target_commitish": git('rev-parse', args.tree).decode().strip(),
145        "body": generate_relnotes(args.tree, args.version),
146    }
147    assets = [
148        generate_asset(args.version, args.tree, 'tar.gz'),
149        generate_asset(args.version, args.tree, 'zip'),
150    ]
151
152    if args.dryrun:
153        for k, v in params.items():
154            print('{}: {}'.format(k, v))
155        for asset in assets:
156            print('asset: name={}, label={}, mimetype={}, bytes={}'.format(asset.name, asset.label, asset.mimetype, len(asset.data)))
157        return
158
159    try:
160        url = 'https://api.github.com/repos/{}/releases'.format(args.repository)
161        response = post(url, json.dumps(params).encode(), 'application/json', args.user, args.password)
162    except Error as e:
163        raise Error('Could not create release: ' + str(e))
164
165    for asset in assets:
166        try:
167            url = list(urllib.parse.urlparse(response['upload_url'].split('{?')[0]))
168            url[4] = urllib.parse.urlencode({ 'name': asset.name, 'label': asset.label })
169            post(urllib.parse.urlunparse(url), asset.data, asset.mimetype, args.user, args.password)
170        except Error as e:
171            raise Error('Could not upload asset: ' + str(e))
172
173def main():
174    parser = argparse.ArgumentParser(description='Create a libgit2 release')
175    parser.add_argument('--tree', default='HEAD', help='tree to create release for (default: HEAD)')
176    parser.add_argument('--dryrun', action='store_true', help='generate release, but do not post it')
177    parser.add_argument('--repository', default='libgit2/libgit2', help='GitHub repository to create repository in')
178    parser.add_argument('--user', help='user to authenticate as')
179    parser.add_argument('--password', help='password to authenticate with')
180    parser.add_argument('version', type=Version, help='version of the new release')
181    args = parser.parse_args()
182
183    verify_version(args.version)
184    release(args)
185
186if __name__ == '__main__':
187    try:
188        main()
189    except Error as e:
190        print(e)
191        sys.exit(1)
192