215 lines
7.1 KiB
Python
Executable File
215 lines
7.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright 2022 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()
|