master
py 225 lines 8.57 KB
Raw
1 # pip install click requests gitpython
2
3 import click
4 import requests
5 import sys
6 import re
7 import os
8 import backoff
9 import functools
10 from git import Repo
11 from urllib.parse import urlparse
12
13 # Reconfigure stdout/stderr to handle unencodable characters (e.g. Unicode in
14 # commit messages) on consoles that use legacy code-pages such as cp1252.
15 sys.stdout.reconfigure(errors='backslashreplace')
16 sys.stderr.reconfigure(errors='backslashreplace')
17
18 @click.command()
19 @click.argument('version', required=True)
20 @click.argument('assets', default=None, nargs=-1)
21 @click.option('--previous', default=None)
22 @click.option('--max-message-lines', default=1)
23 @click.option('--publish', is_flag=True, default=False)
24 @click.option('--no-fetch', is_flag=True, default=False)
25 @click.option('--github-token', default=None)
26 @click.option('--use-current-ref', is_flag=True, default=False)
27 @click.option('--auto-release-notes', is_flag=True, default=False)
28 def main(version: str, previous: str, max_message_lines: int, publish: bool, assets: list, no_fetch: bool, github_token: str, use_current_ref: bool, auto_release_notes: bool):
29 if publish:
30 # Click provides an empty tuple when no assets are passed. Guard against both
31 # an explicit None (older Click versions / direct invocation) and an empty
32 # collection so we do not accidentally create a release without payload.
33 if not assets:
34 raise RuntimeError('--publish requires at least one asset')
35
36 if github_token is None:
37 raise RuntimeError('--publish requires --github_token')
38
39 for e in assets:
40 if not os.path.exists(e):
41 raise RuntimeError(f'Asset not found: {e}')
42
43 if previous is None:
44 previous = get_previous_release(parse_tag(version))
45
46 current_ref = '<current-commit>' if use_current_ref else version
47 print(f'Creating release notes for: {previous} -> {current_ref}', file=sys.stderr)
48
49 changes = ''
50
51 if not auto_release_notes:
52 for e in get_change_list(None if use_current_ref else version, previous, not no_fetch):
53
54 # Detect attached github issues
55 issues = find_github_issues(e.message)
56 pr_description, pr_number = get_github_pr_message(github_token, e.message)
57 if pr_description is not None:
58 issues = issues.union(find_github_issues(pr_description))
59
60 if github_token is not None:
61 issues = filter_github_issues(issues, github_token)
62
63 if len(issues) > 1:
64 print(f'WARNING: found more than 1 github issues in message: {e.message}. Issues: {issues}', file=sys.stderr)
65
66 message = e.message[:-1] if e.message.endswith('\n') else e.message
67
68 # Shrink the message if it's too long
69 lines = message.split('\n')
70 message = '\n'.join([e for e in lines if e][:max_message_lines])
71
72 # Get rid of the github PR #
73 if pr_number is not None:
74 message = message.replace(f'(#{pr_number})', '')
75
76 # Append to the changes (chr(92) == '\n')
77 message = f'{message.replace(chr(92), "")} (solves {",".join(issues)})' if issues else message
78 changes += f'* {message}\n'
79
80 if publish:
81 publish_release(version, changes, assets, auto_release_notes, github_token)
82 else:
83 print(f'\n{changes}')
84
85 @backoff.on_exception(backoff.expo, (requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.RequestException), max_time=600)
86 def get_github_pr_message(token: str, message: str) -> tuple[str | None, str | None]:
87 match = re.search(r'\(#([0-9]+)\)', message)
88 if match is None:
89 print(f'Warning: failed to extract GitHub PR number from message: {message}', file=sys.stderr)
90 return None, None
91
92 pr_number = match.group(1)
93 headers = {'Accept': 'application/vnd.github+json',
94 'Authorization': 'Bearer ' + token,
95 'X-GitHub-Api-Version': '2022-11-28'}
96
97 response = requests.get(f'https://api.github.com/repos/microsoft/wsl/pulls/{pr_number}', timeout=30, headers=headers)
98 response.raise_for_status()
99
100 return response.json()['body'], pr_number
101
102
103 def parse_tag(tag: str) -> list:
104 version = tag.split('.')
105 if len(version) != 3:
106 raise RuntimeError(f'Unexpected tag: {version}')
107
108 return tuple(int(e) for e in version)
109
110 def get_previous_release(version: tuple) -> str:
111 response = requests.get('https://api.github.com/repos/Microsoft/WSL/releases');
112 response.raise_for_status()
113
114 # Find the most recent release with a lower version number than this one
115 versions = [parse_tag(e['tag_name']) for e in response.json()]
116 previous_versions = [e for e in versions if e < version]
117
118 if not previous_versions:
119 raise RuntimeError(f'No previous found on GitHub. Response: {response.json()}')
120
121 return '.'.join(str(e) for e in max(previous_versions))
122
123 def find_github_issues(message: str):
124 # Look for urls first
125 urls = [urlparse(e) for e in re.findall(r"https?://[^\s^\)]+", message)]
126
127 issue_urls = [e for e in urls if e.hostname == 'github.com' and e.path.lower().startswith('/microsoft/wsl/issues/')]
128
129 issues = set(['#' + e.path.split('/')[-1] for e in issue_urls])
130
131 # Then add issue numbers
132 for e in re.findall(r"#\d+", message):
133 issues.add(e)
134
135 return issues
136
137 def filter_github_issues(issues: list, token: str) -> list:
138
139 @functools.cache
140 def is_pr(number: str):
141 headers = {
142 'Accept': 'application/vnd.github+json',
143 'Authorization': 'Bearer ' + token,
144 'X-GitHub-Api-Version': '2022-11-28'
145 }
146
147 response = requests.get(f'https://api.github.com/repos/microsoft/wsl/issues/{number}', timeout=30, headers=headers)
148 response.raise_for_status()
149
150 return response.json().get('pull_request') is not None
151
152 return [e for e in issues if not is_pr(e.replace('#', ''))]
153
154
155 def get_change_list(version: str, previous: str, fetch: bool) -> list:
156 repo = Repo('.')
157
158 # Fetch origin first
159 if fetch and version is not None:
160 repo.remote('origin').fetch(previous)
161
162 # Find both current and previous version tags
163 previous_tag = repo.tag(previous)
164
165 # Set current ref
166 current_ref = repo.tag(version) if version is not None else repo.head
167
168 # Find common root between tags
169 merge_bases = repo.merge_base(previous_tag.commit, current_ref)
170 if len(merge_bases) == 0:
171 raise RuntimeError(f'No merge base found between {version} and {previous}')
172 elif len(merge_bases) > 1:
173 raise RuntimeError(f'Multiple merge bases found between {version} and {previous}')
174
175 # List commits between tags
176 for e in repo.iter_commits(rev=current_ref):
177 if e == merge_bases[0]:
178 return
179
180 yield e
181
182 raise RuntimeError(f'Tag {previous} is not an ancestor of {version}')
183
184
185 @backoff.on_exception(backoff.expo, (requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.RequestException), max_time=600)
186 def publish_release(version: str, changes: str, assets: list, auto_release_notes: bool, token: str):
187 print(f'Creating private GitHub release for: {version}', file=sys.stderr)
188
189 # First create the release
190 headers = {'Accept': 'application/vnd.github+json',
191 'Authorization': 'Bearer ' + token,
192 'X-GitHub-Api-Version': '2022-11-28'}
193
194 content = {'tag_name': version,
195 'target_commitish': 'master',
196 'name': version,
197 "draft":True ,
198 'prerelease':True ,
199 'generate_release_notes': auto_release_notes}
200
201 if changes:
202 content['body'] = changes
203
204 response = requests.post('https://api.github.com/repos/microsoft/wsl/releases', json=content, headers=headers)
205 response.raise_for_status()
206
207 release = response.json()
208 print(f'Created release: {release["url"]}', file=sys.stderr)
209
210 for asset in assets:
211 with open(asset, 'rb') as asset_content:
212 asset_size = os.path.getsize(asset)
213
214 # Append asset to the release assets
215 headers['Content-Type'] = 'application/octet-stream'
216
217 response = requests.post(f'https://uploads.github.com/repos/microsoft/wsl/releases/{release["id"]}/assets?name={os.path.basename(asset)}', headers=headers, data=asset_content)
218 response.raise_for_status()
219
220 print(f'Attached asset: {asset} to release: {response.json()["url"]}', file=sys.stderr)
221
222 print(f'The release has been created. Navigate to {release["html_url"]} to edit the release notes and publish it', file=sys.stderr)
223
224 if __name__ == '__main__':
225 main()