Repository navigation
Expand file tree
/
Copy pathupdate_release_notes.py
More file actions
executable file
·109 lines (95 loc) · 5.27 KB
/
Copy pathupdate_release_notes.py
File metadata and controls
executable file
·109 lines (95 loc) · 5.27 KB
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
#!/usr/bin/env python3
"""Add the interface coverage of a release to its release notes.
The script reads `summary/<project>/<version>.json` from the coverage site and
writes an `## Interface Coverage` section between two markers. Running it again
replaces the section in place, so it is safe to repeat. Without the markers, the
section is added at the end of the release notes. It uses `gh` to read and edit
the release, which needs a token with write access to the repository contents.
"""
import argparse
import json
import subprocess
import time
import urllib.error
import urllib.request
START = '<!-- interface-coverage:start -->'
END = '<!-- interface-coverage:end -->'
def load_summary(source, attempts):
"""Reads a summary from a file or URL. A new site can take some time to appear."""
if not source.startswith(('http://', 'https://')):
with open(source) as file:
return json.load(file)
for attempt in range(attempts):
try:
with urllib.request.urlopen(source, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code != 404 or attempt == attempts - 1:
raise
time.sleep(30)
def section(summary, baselines, profile):
results = [item for item in summary['results']
if item['profile'] == profile and (not baselines or item['baseline'] in baselines)]
if not results:
raise SystemExit(f"No results for {', '.join(baselines) or 'any baseline'} in the '{profile}' profile.")
scopes = list(dict.fromkeys(item['scope'] for item in results))
rows = list(dict.fromkeys(item['baseline'] for item in results))
if baselines:
rows = [baseline for baseline in baselines if baseline in rows]
# The full report shows all platforms of the version.
report = summary['url']
if not report.startswith(('http://', 'https://')):
raise SystemExit('The summary has relative links. Generate the site with a site URL.')
# The first cell, the first baseline in the first scope, links to the APIs changed since the
# previous version.
first = next(item for item in results if item['baseline'] == rows[0] and item['scope'] == scopes[0])
links = [f"Full report: [{summary['project']} {summary['version']}]({report})"]
if first.get('previous') and not first.get('added') and not first.get('removed'):
links.append(f"No API changes since {first['previous']}")
elif first.get('previous'):
links.append(f"[{first['added']:,} APIs added since {first['previous']}]({first['url']}&status=added)")
if first.get('removed'):
links.append(f"[{first['removed']:,} removed]({first['url']}&status=removed)")
lines = [START, '## Interface Coverage', '', ' · '.join(links), '',
'| Baseline | ' + ' | '.join(scopes) + ' |', '| --- | ' + ' | '.join('---:' for _ in scopes) + ' |']
for baseline in rows:
cells = []
for scope in scopes:
item = next((item for item in results if item['baseline'] == baseline and item['scope'] == scope), None)
if item is None:
cells.append('—')
else:
value = 'n/a' if item.get('percent') is None else f"{item['percent']:.2f}%"
cells.append(f"[{value}]({item['url']})")
lines.append(f'| {baseline} | ' + ' | '.join(cells) + ' |')
return '\n'.join(lines + [END])
def replacing_section(body, block):
"""Replaces the section between the markers, or adds it at the end of the notes."""
if START in body and END in body:
before, rest = body.split(START, 1)
return before + block + rest.split(END, 1)[1]
return f'{body}\n\n{block}' if body else block
def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
parser.add_argument('--repository', default='OpenSwiftUIProject/OpenSwiftUI')
parser.add_argument('--version', required=True, help='Release tag.')
parser.add_argument('--summary', required=True, help='Summary JSON path or URL.')
parser.add_argument('--baseline', action='append', default=[], help='Baseline to show. Repeat for more. The default is all.')
parser.add_argument('--profile', default='Default')
parser.add_argument('--attempts', type=int, default=20, help='Attempts to read a summary URL, 30 seconds apart.')
parser.add_argument('--dry-run', action='store_true', help='Print the new release notes instead of editing the release.')
arguments = parser.parse_args()
summary = load_summary(arguments.summary, arguments.attempts)
if summary['version'] != arguments.version:
raise SystemExit(f"The summary is for {summary['version']}, not {arguments.version}.")
block = section(summary, arguments.baseline, arguments.profile)
body = subprocess.check_output(['gh', 'release', 'view', arguments.version, '--repo', arguments.repository,
'--json', 'body', '--jq', '.body'], text=True).rstrip('\n')
body = replacing_section(body, block)
if arguments.dry_run:
print(body)
return
subprocess.run(['gh', 'release', 'edit', arguments.version, '--repo', arguments.repository, '--notes-file', '-'],
input=body + '\n', text=True, check=True)
if __name__ == '__main__':
main()