forked from GoogleCloudPlatform/cloud-foundation-fabric
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathchangelog.py
executable file
·214 lines (186 loc) · 7.07 KB
/
changelog.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#!/usr/bin/env python3
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import click
import collections
import ghapi.all
import iso8601
LINK_MARKER = '<!-- markdown-link-check-disable -->'
ORG = 'GoogleCloudPlatform'
REPO = 'cloud-foundation-fabric'
URL = f'https://github.com/{ORG}/{REPO}'
PullRequest = collections.namedtuple('PullRequest',
'id author title merged_at labels')
FileRelease = collections.namedtuple('FileRelease',
'name published since content')
GitRelease = collections.namedtuple('GitRelease', 'name published since pulls')
Section = collections.namedtuple('Section', 'text')
class Error(Exception):
pass
def _paginate(method, **kw):
'Paginate GitHub API call.'
page = 1
while True:
result = method(page=page, per_page=100, **kw)
for item in result:
yield item
if len(result) < 100:
break
page += 1
def changelog_load(path):
'Parse changelog file and return structured data.'
releases = []
try:
with open(path) as f:
for l in f.readlines():
l = l.strip()
if l.startswith(LINK_MARKER):
break
if l.startswith('## '):
name, _, date = l[3:].partition(' - ')
releases.append(FileRelease(name[1:-1], date, None, []))
elif releases:
releases[-1].content.append(l)
return releases
except (IOError, OSError) as e:
raise Error(f'Cannot open {path}: {e.args[0]}')
def changelog_dumps(file_releases, git_releases=None):
'Return formatted changelog from structured data, overriding versions.'
git_releases = git_releases or {}
buffer = [
('# Changelog\n\n'
'All notable changes to this project will be documented in this file.\n'
'<!-- markdownlint-disable MD024 -->\n')
]
ref_buffer = ['<!-- markdown-link-check-disable -->']
for i, release in enumerate(file_releases):
name, published, _, items = release
prev_name = file_releases[i +
1].name if i + 1 < len(file_releases) else '0.1'
if name != 'Unreleased':
buffer.append(f'## [{name}] - {published}')
ref_buffer.append(f'[{name}]: {URL}/compare/v{prev_name}...v{name}')
else:
buffer.append(f'## [{name}]')
ref_buffer.append(f'[Unreleased]: {URL}/compare/v{prev_name}...HEAD')
release = git_releases.get(name, git_releases.get(f'v{name}'))
if release:
buffer.append(f'<!-- {release.published} < {release.since} -->')
pulls = group_pulls(release.pulls)
for k in sorted(pulls.keys(), key=lambda s: s or ''):
if k is not None:
buffer.append(f'### {k}\n')
for pull in pulls[k]:
buffer.append(format_pull(pull))
buffer.append('')
else:
buffer.append('\n'.join(items))
return '\n'.join(buffer + ref_buffer + [''])
def format_pull(pull):
'Format pull request.'
url = 'https://github.com'
pull_url = f'{url}/{ORG}/{REPO}/pull'
prefix = ''
if 'incompatible change' in pull.labels:
prefix = '**incompatible change:** '
return (f'- [[#{pull.id}]({pull_url}/{pull.id})] '
f'{prefix}'
f'{pull.title} '
f'([{pull.author}]({url}/{pull.author})) <!-- {pull.merged_at} -->')
def group_pulls(pulls):
pulls.sort(key=lambda p: p.merged_at, reverse=True)
groups = {None: []}
for pull in pulls:
labels = [l[3:] for l in pull.labels if l.startswith('on:')]
if not labels:
groups[None].append(pull)
continue
for label in labels:
group = groups.setdefault(label.upper(), [])
group.append(pull)
return groups
def get_api(token, owner=ORG, name=REPO):
'Get GitHub API object.'
return ghapi.all.GhApi(owner=owner, repo=name, token=token)
def get_pulls(api):
'Get all pull requests (GH sometimes forgets pulls with filters).'
pulls = []
# this should be done on the fly with sort='updated', direction='desc'
# if the API could be trusted (they cannot)
for p in _paginate(api.pulls.list, base='master', state='closed'):
try:
merged_at = iso8601.parse_date(p['merged_at'])
except iso8601.ParseError:
continue
pulls.append(
PullRequest(p['number'], p['user']['login'], p['title'], merged_at,
[l['name'] for l in p['labels']]))
pulls.sort(key=lambda p: p.merged_at, reverse=True)
return pulls
def get_release_pulls(api, releases):
'Get and add pull requests for releases.'
i = 0
for p in get_pulls(api):
if releases[i].published and p.merged_at >= releases[i].published:
continue
if releases[i].since and p.merged_at <= releases[i].since:
i += 1
if i == len(releases):
break
releases[i].pulls.append(p)
return releases
def get_releases(api, filter_names=None):
'Get releases with optional filter on release names.'
Buffer = collections.namedtuple('Buffer', 'name published')
buffer = Buffer('Unreleased', None)
for r in _paginate(api.repos.list_releases):
published = iso8601.parse_date(r['published_at'])
if not filter_names or buffer.name in filter_names:
yield GitRelease(buffer.name, buffer.published, published, [])
buffer = Buffer(r['name'], published)
if buffer and (not filter_names or buffer.name in filter_names):
yield GitRelease(buffer.name, buffer.published, None, [])
@click.command
@click.option('--all-releases', is_flag=True, default=False,
help='All releases.')
@click.option(
'--release', required=False, default=['Unreleased'], multiple=True,
help='Release to replace, specify multiple times for more than one version.'
)
@click.option('--token', required=True, envvar='GH_TOKEN',
help='GitHub API token.')
@click.option('--write', '-w', is_flag=True, required=False, default=False,
help='Write modified changelog file.')
@click.argument('changelog', required=False, default='CHANGELOG.md',
type=click.Path(exists=True))
def main(token, changelog='CHANGELOG.md', all_releases=False, release=None,
write=False):
api = get_api(token)
release = [] if all_releases else release
releases = [r for r in get_releases(api, release)]
releases = {r.name: r for r in get_release_pulls(api, releases)}
try:
changelog_releases = changelog_load(changelog)
result = changelog_dumps(changelog_releases, releases)
except Error as e:
raise SystemExit(f'Cannot read or generate changelog: {e.args[0]}')
if not write:
print(result)
else:
try:
open(changelog, 'w').write(result)
except (IOError, OSError) as e:
raise SystemExit('Cannot write to changelog file.')
if __name__ == '__main__':
main()