cloud-foundation-fabric/tools/changelog.py

215 lines
7.1 KiB
Python
Executable File

#!/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()