|
1
|
|
|
#!/usr/bin/python3 |
|
2
|
|
|
# -*- coding: utf-8 -*- |
|
3
|
|
|
|
|
4
|
|
|
""" |
|
5
|
|
|
Script created to help maintainers during the release process by automating Github tasks. |
|
6
|
|
|
""" |
|
7
|
|
|
|
|
8
|
|
|
# References: |
|
9
|
|
|
# - https://developer.github.com/v3/libraries/ |
|
10
|
|
|
# - https://github.com/PyGithub/PyGithub |
|
11
|
|
|
# - https://pygithub.readthedocs.io/en/latest/github_objects.html |
|
12
|
|
|
# - https://docs.github.com/en/rest |
|
13
|
|
|
|
|
14
|
|
|
from datetime import datetime, timedelta |
|
15
|
|
|
from github import Github |
|
16
|
|
|
import argparse |
|
17
|
|
|
import configparser |
|
18
|
|
|
import os.path |
|
19
|
|
|
import re |
|
20
|
|
|
import subprocess |
|
21
|
|
|
|
|
22
|
|
|
|
|
23
|
|
|
def get_parameter_from_ini(config_file, section, parameter) -> str: |
|
24
|
|
|
config = configparser.ConfigParser() |
|
25
|
|
|
try: |
|
26
|
|
|
config.read(config_file) |
|
27
|
|
|
return config[section][parameter] |
|
28
|
|
|
except Exception as e: |
|
29
|
|
|
print(f'Error: {e} entry was not found!') |
|
30
|
|
|
|
|
31
|
|
|
|
|
32
|
|
|
def create_ini_file_template(creds_file): |
|
33
|
|
|
try: |
|
34
|
|
|
new_creds_file = os.path.expanduser(creds_file) |
|
35
|
|
|
with open(new_creds_file, mode='w', encoding='utf-8') as file: |
|
36
|
|
|
file.write( |
|
37
|
|
|
'[DEFAULT]\n' |
|
38
|
|
|
'github_token = <generate your token in https://github.com/settings/tokens>\n') |
|
39
|
|
|
print(f'Great! {new_creds_file} was created! Edit it to include your personal token.') |
|
40
|
|
|
except Exception as e: |
|
41
|
|
|
print(f'Error: {e}') |
|
42
|
|
|
|
|
43
|
|
|
|
|
44
|
|
|
def get_github_token(creds_file) -> str: |
|
45
|
|
|
creds_file = os.path.expanduser(creds_file) |
|
46
|
|
|
if os.path.exists(creds_file): |
|
47
|
|
|
return get_parameter_from_ini(creds_file, "DEFAULT", "github_token") |
|
48
|
|
|
else: |
|
49
|
|
|
print(f'{creds_file} file was not found!') |
|
50
|
|
|
if get_confirmation(f'Would you like to create the "{creds_file}" file?'): |
|
51
|
|
|
create_ini_file_template(creds_file) |
|
52
|
|
|
|
|
53
|
|
|
|
|
54
|
|
|
def create_github_session(creds_file) -> object: |
|
55
|
|
|
token = get_github_token(creds_file) |
|
56
|
|
|
if not token: |
|
57
|
|
|
print('WARNING: No token found. The API queries are very limited without a token.\n') |
|
58
|
|
|
return Github(token) |
|
59
|
|
|
|
|
60
|
|
|
|
|
61
|
|
|
def get_confirmation(question) -> bool: |
|
62
|
|
|
answer = str(input(f'{question} (Y/N): ')).lower().strip() |
|
63
|
|
|
return answer[:1] == 'y' |
|
64
|
|
|
|
|
65
|
|
|
|
|
66
|
|
|
def get_repo_object(session, repo_id) -> object: |
|
67
|
|
|
return session.get_repo(repo_id) |
|
68
|
|
|
|
|
69
|
|
|
|
|
70
|
|
|
def get_repo_root_path() -> str: |
|
71
|
|
|
root_path = get_script_path() |
|
72
|
|
|
return os.path.dirname(root_path) |
|
73
|
|
|
|
|
74
|
|
|
|
|
75
|
|
|
def get_script_path() -> str: |
|
76
|
|
|
return os.path.dirname(os.path.realpath(__file__)) |
|
77
|
|
|
|
|
78
|
|
|
|
|
79
|
|
|
def show_git_diff(): |
|
80
|
|
|
subprocess.run(['git', 'diff'], cwd=get_repo_root_path()) |
|
81
|
|
|
print('\nPlease, review the changes and propose a PR to "master" branch.') |
|
82
|
|
|
|
|
83
|
|
|
|
|
84
|
|
|
# Repository Branches |
|
85
|
|
|
def filter_repo_branch_by_name(branches, name) -> object: |
|
86
|
|
|
return [branch for branch in branches if branch.name == name] |
|
87
|
|
|
|
|
88
|
|
|
|
|
89
|
|
|
def get_repo_branches(repo) -> list: |
|
90
|
|
|
return repo.get_branches() |
|
91
|
|
|
|
|
92
|
|
|
|
|
93
|
|
|
def get_repo_stable_branch(repo) -> object: |
|
94
|
|
|
branches = get_repo_branches(repo) |
|
95
|
|
|
return filter_repo_branch_by_name(branches, "stable") |
|
96
|
|
|
|
|
97
|
|
|
|
|
98
|
|
|
def get_old_stabilization_branch(repo) -> object: |
|
99
|
|
|
branches = get_repo_branches(repo) |
|
100
|
|
|
latest_version = get_latest_version(repo) |
|
101
|
|
|
branch_name = f'stabilization-v{latest_version}' |
|
102
|
|
|
return filter_repo_branch_by_name(branches, branch_name) |
|
103
|
|
|
|
|
104
|
|
|
|
|
105
|
|
|
def remove_old_stabilization_branch(repo, branch) -> None: |
|
106
|
|
|
if get_confirmation(f'Are you sure about removing the "{branch.name}" branch?'): |
|
107
|
|
|
# https://github.com/PyGithub/PyGithub/issues/1570 |
|
108
|
|
|
try: |
|
109
|
|
|
branch_ref = repo.get_git_ref(f'heads/{branch.name}') |
|
110
|
|
|
branch_ref.delete() |
|
111
|
|
|
except Exception as e: |
|
112
|
|
|
print(f'Error: {e}') |
|
113
|
|
|
else: |
|
114
|
|
|
print(f'Aborted! {branch.name} branch not removed.') |
|
115
|
|
|
|
|
116
|
|
|
|
|
117
|
|
|
# Repository Contributors |
|
118
|
|
|
def get_contributors_commit_diff(commit) -> str: |
|
119
|
|
|
commit_diff = subprocess.run( |
|
120
|
|
|
['git', 'show', '--word-diff', commit, 'Contributors.md'], |
|
121
|
|
|
capture_output=True, text=True, cwd=get_repo_root_path()) |
|
122
|
|
|
return commit_diff.stdout |
|
123
|
|
|
|
|
124
|
|
|
|
|
125
|
|
|
def get_contributors_last_commit() -> str: |
|
126
|
|
|
last_commit = subprocess.run( |
|
127
|
|
|
['git', 'log', '--pretty=format:%h', '-1', '--', 'Contributors.md'], |
|
128
|
|
|
capture_output=True, text=True, cwd=get_repo_root_path()) |
|
129
|
|
|
return last_commit.stdout |
|
130
|
|
|
|
|
131
|
|
|
|
|
132
|
|
|
def get_contributors_last_update() -> str: |
|
133
|
|
|
last_commit = get_contributors_last_commit() |
|
134
|
|
|
last_commit_diff = get_contributors_commit_diff(last_commit) |
|
135
|
|
|
|
|
136
|
|
|
for line in last_commit_diff.split('\n'): |
|
137
|
|
|
# The "generate_contributors.py" creates a commend in the beginning of the file. |
|
138
|
|
|
# This comment informs when the file was updated. |
|
139
|
|
|
if 'Last Modified:' in line: |
|
140
|
|
|
elements = line.split('{+') |
|
141
|
|
|
datetime = elements[1].replace('+}', '') |
|
142
|
|
|
return datetime |
|
143
|
|
|
|
|
144
|
|
|
|
|
145
|
|
|
def get_new_contributors(commit) -> str: |
|
146
|
|
|
last_commit_diff = get_contributors_commit_diff(commit) |
|
147
|
|
|
new_contributors = [] |
|
148
|
|
|
|
|
149
|
|
|
for line in last_commit_diff.split('\n'): |
|
150
|
|
|
if line.startswith('{+'): |
|
151
|
|
|
for chars in ['{+', '+}']: |
|
152
|
|
|
line = line.replace(chars, '') |
|
153
|
|
|
new_contributors.append(line) |
|
154
|
|
|
return '\n'.join(new_contributors) |
|
155
|
|
|
|
|
156
|
|
|
|
|
157
|
|
|
def is_contributors_list_updated(date_string) -> bool: |
|
158
|
|
|
date = datetime.strptime(date_string[:16], '%Y-%m-%d %H:%M') |
|
159
|
|
|
# As a rule of thumbs, this function consider contributors list |
|
160
|
|
|
# updated if not older than 2 weeks. |
|
161
|
|
|
two_weeks_back = datetime.now() - timedelta(days=15) |
|
162
|
|
|
if date < two_weeks_back: |
|
163
|
|
|
return False |
|
164
|
|
|
return True |
|
165
|
|
|
|
|
166
|
|
|
|
|
167
|
|
|
def update_contributors(): |
|
168
|
|
|
last_update = get_contributors_last_update() |
|
169
|
|
|
# Currently, this script considers contributors lists is already updated if not |
|
170
|
|
|
# older than 2 weeks. |
|
171
|
|
|
if is_contributors_list_updated(last_update): |
|
172
|
|
|
print(f'It is all fine, the contributors list was updated in {last_update}') |
|
173
|
|
|
else: |
|
174
|
|
|
print(f'Contributors list last update was in {last_update}. I can update it for you.') |
|
175
|
|
|
utils_scripts_path = get_script_path() |
|
176
|
|
|
contributors_script = os.path.join(utils_scripts_path, 'generate_contributors.py') |
|
177
|
|
|
subprocess.run( |
|
178
|
|
|
[f'PYTHONPATH=. {contributors_script}'], shell=True, cwd=get_repo_root_path()) |
|
179
|
|
|
show_git_diff() |
|
180
|
|
|
|
|
181
|
|
|
|
|
182
|
|
|
# Repository Milestones |
|
183
|
|
|
def has_milestone_version_format(milestone) -> bool: |
|
184
|
|
|
regex = re.compile(r'\d\.\d\.\d') |
|
185
|
|
|
return regex.match(milestone.title) |
|
186
|
|
|
|
|
187
|
|
|
|
|
188
|
|
|
def filter_version_milestones(milestones) -> list: |
|
189
|
|
|
version_milestones = [] |
|
190
|
|
|
for milestone in milestones: |
|
191
|
|
|
if has_milestone_version_format(milestone): |
|
192
|
|
|
version_milestones.append(milestone) |
|
193
|
|
|
return version_milestones |
|
194
|
|
|
|
|
195
|
|
|
|
|
196
|
|
|
def get_repo_milestones(repo) -> list: |
|
197
|
|
|
return repo.get_milestones(state="all", direction="desc") |
|
198
|
|
|
|
|
199
|
|
|
|
|
200
|
|
|
def get_latest_version_milestone(repo) -> object: |
|
201
|
|
|
milestones = get_repo_milestones(repo) |
|
202
|
|
|
version_milestones = filter_version_milestones(milestones) |
|
203
|
|
|
latest_version_milestone = version_milestones[0] |
|
204
|
|
|
# It is not ensured the returned milestones list is ordered as expected. |
|
205
|
|
|
# This function ensures the last milestone based on titles. |
|
206
|
|
|
for milestone in version_milestones: |
|
207
|
|
|
if milestone.title > latest_version_milestone.title: |
|
208
|
|
|
latest_version_milestone = milestone |
|
209
|
|
|
return latest_version_milestone |
|
210
|
|
|
|
|
211
|
|
|
|
|
212
|
|
|
def get_version_milestone(repo, version) -> object: |
|
213
|
|
|
milestones = get_repo_milestones(repo) |
|
214
|
|
|
version_milestones = filter_version_milestones(milestones) |
|
215
|
|
|
for milestone in version_milestones: |
|
216
|
|
|
if milestone.title == version: |
|
217
|
|
|
return milestone |
|
218
|
|
|
|
|
219
|
|
|
|
|
220
|
|
|
def close_milestone(milestone) -> None: |
|
221
|
|
|
if milestone.state == "closed": |
|
222
|
|
|
print(f'Nice. The "{milestone}" milestone is already closed.') |
|
223
|
|
|
return |
|
224
|
|
|
|
|
225
|
|
|
if get_confirmation(f'The "{milestone}" milestone should be closed. Ok?'): |
|
226
|
|
|
try: |
|
227
|
|
|
milestone.edit(milestone.title, state="closed") |
|
228
|
|
|
except Exception as e: |
|
229
|
|
|
print(f'Error: {e}') |
|
230
|
|
|
exit(1) |
|
231
|
|
|
else: |
|
232
|
|
|
print(f'Aborted! {milestone} milestone not closed.') |
|
233
|
|
|
|
|
234
|
|
|
|
|
235
|
|
|
def create_repo_milestone(repo, name) -> None: |
|
236
|
|
|
if get_version_milestone(repo, name): |
|
237
|
|
|
print(f'Great! The "{name}" milestone is already created.') |
|
238
|
|
|
return |
|
239
|
|
|
|
|
240
|
|
|
latest_release = get_latest_release(repo) |
|
241
|
|
|
estimated_release_date = get_next_release_date(latest_release.published_at) |
|
242
|
|
|
if get_confirmation(f'Are you sure about creating the "{name}" milestone?'): |
|
243
|
|
|
try: |
|
244
|
|
|
repo.create_milestone( |
|
245
|
|
|
title=name, description=f'Milestone for the release {name}', |
|
246
|
|
|
due_on=estimated_release_date) |
|
247
|
|
|
except Exception as e: |
|
248
|
|
|
print(f'Error: {e}') |
|
249
|
|
|
exit(1) |
|
250
|
|
|
else: |
|
251
|
|
|
print(f'Aborted! {name} milestone not created.') |
|
252
|
|
|
|
|
253
|
|
|
|
|
254
|
|
|
# Repository Releases |
|
255
|
|
|
def get_repo_releases(repo) -> list: |
|
256
|
|
|
return repo.get_releases() |
|
257
|
|
|
|
|
258
|
|
|
|
|
259
|
|
|
def get_latest_release(repo) -> object: |
|
260
|
|
|
releases = get_repo_releases(repo) |
|
261
|
|
|
# The API returns the list already ordered by published date. |
|
262
|
|
|
return releases[0] |
|
263
|
|
|
|
|
264
|
|
|
|
|
265
|
|
|
# Repository Versions |
|
266
|
|
|
def extract_version_from_tag_name(version) -> str: |
|
267
|
|
|
return version.split("v")[1] |
|
268
|
|
|
|
|
269
|
|
|
|
|
270
|
|
|
def get_latest_version(repo) -> str: |
|
271
|
|
|
release = get_latest_release(repo) |
|
272
|
|
|
return extract_version_from_tag_name(release.tag_name) |
|
273
|
|
|
|
|
274
|
|
|
|
|
275
|
|
|
# Repository Next Release |
|
276
|
|
|
def bump_version(current_version, bump_position) -> str: |
|
277
|
|
|
# version format example: 0.1.64 |
|
278
|
|
|
if bump_position == 'minor': |
|
279
|
|
|
position = 2 |
|
280
|
|
|
elif bump_position == 'major': |
|
281
|
|
|
position = 1 |
|
282
|
|
|
else: |
|
283
|
|
|
position = 0 |
|
284
|
|
|
|
|
285
|
|
|
split_version = current_version.split('.') |
|
286
|
|
|
bumped = int(split_version[position]) + 1 |
|
287
|
|
|
split_version[position] = str(bumped) |
|
288
|
|
|
return ".".join(split_version) |
|
289
|
|
|
|
|
290
|
|
|
|
|
291
|
|
|
def bump_master_version(repo): |
|
292
|
|
|
content_changed = False |
|
293
|
|
|
old_version = get_next_release_version(repo) |
|
294
|
|
|
new_version = bump_version(old_version, 'minor') |
|
295
|
|
|
old_minor_version = old_version.split('.')[2] |
|
296
|
|
|
new_minor_version = new_version.split('.')[2] |
|
297
|
|
|
repo_root = get_repo_root_path() |
|
298
|
|
|
with open(os.path.join(repo_root, "CMakeLists.txt"), mode='r', encoding='utf-8') as file: |
|
299
|
|
|
content = file.readlines() |
|
300
|
|
|
|
|
301
|
|
|
for ln, line in enumerate(content): |
|
302
|
|
|
if f'set(SSG_PATCH_VERSION {old_minor_version}' in line: |
|
303
|
|
|
content[ln] = content[ln].replace(old_minor_version, new_minor_version) |
|
304
|
|
|
content_changed = True |
|
305
|
|
|
|
|
306
|
|
|
if content_changed: |
|
307
|
|
|
with open(os.path.join(repo_root, "CMakeLists.txt"), mode='w', encoding='utf-8') as file: |
|
308
|
|
|
file.writelines(content) |
|
309
|
|
|
show_git_diff() |
|
310
|
|
|
else: |
|
311
|
|
|
print('Great! The version in CMakeLists.txt file is already updated.') |
|
312
|
|
|
|
|
313
|
|
|
|
|
314
|
|
|
def describe_next_release_state(repo) -> str: |
|
315
|
|
|
if is_next_release_in_progress(repo): |
|
316
|
|
|
return 'In Stabilization Phase' |
|
317
|
|
|
else: |
|
318
|
|
|
return 'Scheduled' |
|
319
|
|
|
|
|
320
|
|
|
|
|
321
|
|
|
def get_friday_that_follows(date) -> datetime: |
|
322
|
|
|
return date + timedelta((4 - date.weekday()) % 7) |
|
323
|
|
|
|
|
324
|
|
|
|
|
325
|
|
|
def get_monday_that_follows(date) -> datetime: |
|
326
|
|
|
return date + timedelta((0 - date.weekday()) % 7) |
|
327
|
|
|
|
|
328
|
|
|
|
|
329
|
|
|
def get_next_stabilization_date(release_date) -> datetime: |
|
330
|
|
|
two_weeks_before = release_date - timedelta(weeks=2) |
|
331
|
|
|
stabilization_monday = get_monday_that_follows(two_weeks_before) |
|
332
|
|
|
return stabilization_monday.date() |
|
333
|
|
|
|
|
334
|
|
|
|
|
335
|
|
|
def get_next_release_date(latest_release_date) -> datetime: |
|
336
|
|
|
two_months_ahead = latest_release_date + timedelta(days=60) |
|
337
|
|
|
return get_friday_that_follows(two_months_ahead) |
|
338
|
|
|
|
|
339
|
|
|
|
|
340
|
|
|
def is_next_release_in_progress(repo) -> bool: |
|
341
|
|
|
stabilization_branch = get_next_stabilization_branch(repo) |
|
342
|
|
|
branches_names = [branch.name for branch in get_repo_branches(repo)] |
|
343
|
|
|
if stabilization_branch in branches_names: |
|
344
|
|
|
return True |
|
345
|
|
|
else: |
|
346
|
|
|
return False |
|
347
|
|
|
|
|
348
|
|
|
|
|
349
|
|
|
def get_next_release_version(repo) -> str: |
|
350
|
|
|
current_version = get_latest_version(repo) |
|
351
|
|
|
next_version = bump_version(current_version, 'minor') |
|
352
|
|
|
return next_version |
|
353
|
|
|
|
|
354
|
|
|
|
|
355
|
|
|
def get_next_stabilization_branch(repo) -> str: |
|
356
|
|
|
next_release = get_next_release_version(repo) |
|
357
|
|
|
return f'stabilization-v{next_release}' |
|
358
|
|
|
|
|
359
|
|
|
|
|
360
|
|
|
# Communication |
|
361
|
|
|
def get_date_for_message(date) -> datetime: |
|
362
|
|
|
return date.strftime("%B %d, %Y") |
|
363
|
|
|
|
|
364
|
|
|
|
|
365
|
|
|
def get_release_highlights(release) -> str: |
|
366
|
|
|
highlights = [] |
|
367
|
|
|
for line in release.body.split('\r\n'): |
|
368
|
|
|
if '### Important Highlights' in line: |
|
369
|
|
|
continue |
|
370
|
|
|
if '###' in line: |
|
371
|
|
|
break |
|
372
|
|
|
if line: |
|
373
|
|
|
highlights.append(line) |
|
374
|
|
|
return '\n'.join(highlights) |
|
375
|
|
|
|
|
376
|
|
|
|
|
377
|
|
|
def get_release_start_message(repo) -> str: |
|
378
|
|
|
latest_release = get_latest_release(repo) |
|
379
|
|
|
next_release_version = get_next_release_version(repo) |
|
380
|
|
|
next_release_date = get_next_release_date(latest_release.published_at) |
|
381
|
|
|
date = get_date_for_message(next_release_date) |
|
382
|
|
|
branch = get_next_stabilization_branch(repo) |
|
383
|
|
|
|
|
384
|
|
|
future_version = bump_version(next_release_version, 'minor') |
|
385
|
|
|
future_release_date = get_next_release_date(next_release_date) |
|
386
|
|
|
future_date = get_date_for_message(future_release_date) |
|
387
|
|
|
future_stabilization_date = get_next_stabilization_date(future_release_date) |
|
388
|
|
|
future_date_stabilization = get_date_for_message(future_stabilization_date) |
|
389
|
|
|
|
|
390
|
|
|
template = f''' |
|
391
|
|
|
Subject: stabilization of v{next_release_version} |
|
392
|
|
|
|
|
393
|
|
|
Hello all, |
|
394
|
|
|
|
|
395
|
|
|
The release of Content version {next_release_version} is scheduled for {date}. |
|
396
|
|
|
As part of the release process, a stabilization branch was created. |
|
397
|
|
|
Issues and PRs that were not solved were moved to the {future_version} milestone. |
|
398
|
|
|
|
|
399
|
|
|
Any bug fixes you would like to include in release {next_release_version} should be |
|
400
|
|
|
proposed for the *{branch}* and *master* branches as a measure to avoid |
|
401
|
|
|
potential conflicts between these branches. |
|
402
|
|
|
|
|
403
|
|
|
The next version, {future_version}, is scheduled to be released on {future_date}, |
|
404
|
|
|
with the stabilization phase starting on {future_date_stabilization}. |
|
405
|
|
|
|
|
406
|
|
|
Regards,''' |
|
407
|
|
|
return template |
|
408
|
|
|
|
|
409
|
|
|
|
|
410
|
|
|
def get_release_end_message(repo) -> str: |
|
411
|
|
|
latest_release = get_latest_release(repo) |
|
412
|
|
|
latest_release_url = latest_release.html_url |
|
413
|
|
|
highlights = get_release_highlights(latest_release) |
|
414
|
|
|
last_commit = get_contributors_last_commit() |
|
415
|
|
|
new_contributors = get_new_contributors(last_commit) |
|
416
|
|
|
released_version = get_latest_version(repo) |
|
417
|
|
|
|
|
418
|
|
|
for asset in latest_release.get_assets(): |
|
419
|
|
|
if asset.content_type == 'application/x-bzip2': |
|
420
|
|
|
source_tarball = asset.browser_download_url |
|
421
|
|
|
elif asset.content_type == 'application/zip': |
|
422
|
|
|
prebuild_zip = asset.browser_download_url |
|
423
|
|
|
elif '.tar.bz2.sha512' in asset.name: |
|
424
|
|
|
source_tarball_hash = asset.browser_download_url |
|
425
|
|
|
else: |
|
426
|
|
|
prebuild_zip_hash = asset.browser_download_url |
|
427
|
|
|
|
|
428
|
|
|
# It would be more readable if this multiline string would be indented. However, this makes |
|
429
|
|
|
# the final text weird, after the "format" substitutions, when highlights and new_contributors |
|
430
|
|
|
# have multilines too. So, the readability was compromised in favor of simplicity and user |
|
431
|
|
|
# experience. The user only needs to copy and paste, without removing leading spaces. |
|
432
|
|
|
template = f''' |
|
433
|
|
|
Subject: ComplianceAsCode/content v{released_version} |
|
434
|
|
|
|
|
435
|
|
|
Hello all, |
|
436
|
|
|
|
|
437
|
|
|
ComplianceAsCode/Content v{released_version} is out. |
|
438
|
|
|
|
|
439
|
|
|
Some of the highlights of this release are: |
|
440
|
|
|
{highlights} |
|
441
|
|
|
|
|
442
|
|
|
Welcome to the new contributors: |
|
443
|
|
|
{new_contributors} |
|
444
|
|
|
|
|
445
|
|
|
For full release notes, please have a look at: |
|
446
|
|
|
{latest_release_url} |
|
447
|
|
|
|
|
448
|
|
|
Pre-built content: {prebuild_zip} |
|
449
|
|
|
SHA-512 hash: {prebuild_zip_hash} |
|
450
|
|
|
|
|
451
|
|
|
Source tarball: {source_tarball} |
|
452
|
|
|
SHA-512 hash: {source_tarball_hash} |
|
453
|
|
|
|
|
454
|
|
|
Thank you to everyone who contributed! |
|
455
|
|
|
|
|
456
|
|
|
Regards,''' |
|
457
|
|
|
return template |
|
458
|
|
|
|
|
459
|
|
|
|
|
460
|
|
|
def print_communication_channels(phase='release') -> None: |
|
461
|
|
|
gitter_lnk = 'https://gitter.im/Compliance-As-Code-The/content' |
|
462
|
|
|
ghd_lnk = 'https://github.com/ComplianceAsCode/content/discussions/new?category=announcements' |
|
463
|
|
|
twitter_lnk = 'https://twitter.com/openscap' |
|
464
|
|
|
ssg_mail = '[email protected]' |
|
465
|
|
|
openscap_mail = '[email protected]' |
|
466
|
|
|
|
|
467
|
|
|
print('Please, share the following message in:\n' |
|
468
|
|
|
f'* Gitter ({gitter_lnk})\n' |
|
469
|
|
|
f'* Github Discussion ({ghd_lnk})\n' |
|
470
|
|
|
f'* SCAP Security Guide Mail List ({ssg_mail})') |
|
471
|
|
|
|
|
472
|
|
|
if phase == "finish": |
|
473
|
|
|
print(f'* OpenSCAP Mail List ({openscap_mail})\n' |
|
474
|
|
|
f'* Twitter ({twitter_lnk})') |
|
475
|
|
|
|
|
476
|
|
|
|
|
477
|
|
|
# Issues and PRs |
|
478
|
|
|
def filter_items_outdated_milestone(objects_list, milestone) -> list: |
|
479
|
|
|
items_old_milestone = [] |
|
480
|
|
|
for item in objects_list: |
|
481
|
|
|
if has_milestone_version_format(item.milestone) and \ |
|
482
|
|
|
item.milestone.title != milestone.title: |
|
483
|
|
|
items_old_milestone.append(item) |
|
484
|
|
|
return items_old_milestone |
|
485
|
|
|
|
|
486
|
|
|
|
|
487
|
|
|
def show_outdated_items(items_to_update): |
|
488
|
|
|
count = len(items_to_update) |
|
489
|
|
|
print(f'{count} open issues have an outdated milestone. Here are their links:') |
|
490
|
|
|
for item in items_to_update: |
|
491
|
|
|
print(f'{item.number:5} - {item.html_url:65} - {item.milestone.title} - {item.title}') |
|
492
|
|
|
|
|
493
|
|
|
|
|
494
|
|
|
def filter_outdated_items(repo, objects_list) -> list: |
|
495
|
|
|
latest_milestone = get_latest_version_milestone(repo) |
|
496
|
|
|
items_to_update = filter_items_outdated_milestone(objects_list, latest_milestone) |
|
497
|
|
|
print('INFO: Please, note that in Github API the Pull Requests are also Issues objects but ' |
|
498
|
|
|
'with some few differences in properties.') |
|
499
|
|
|
if items_to_update: |
|
500
|
|
|
show_outdated_items(items_to_update) |
|
501
|
|
|
if get_confirmation( |
|
502
|
|
|
'Are you sure about updating the milestone in these open issues?'): |
|
503
|
|
|
return items_to_update |
|
504
|
|
|
else: |
|
505
|
|
|
print('Aborted! Milestones not updated in open issues.') |
|
506
|
|
|
else: |
|
507
|
|
|
print('Great! There is no open issue with an outdated milestone.') |
|
508
|
|
|
return [] |
|
509
|
|
|
|
|
510
|
|
|
|
|
511
|
|
|
def get_items_with_milestone(object_list) -> list: |
|
512
|
|
|
with_milestone = [] |
|
513
|
|
|
for item in object_list: |
|
514
|
|
|
if item.milestone: |
|
515
|
|
|
with_milestone.append(item) |
|
516
|
|
|
return with_milestone |
|
517
|
|
|
|
|
518
|
|
|
|
|
519
|
|
|
def get_open_issues_with_milestone(repo) -> list: |
|
520
|
|
|
open_issues = repo.get_issues(state='open', direction='asc') |
|
521
|
|
|
return get_items_with_milestone(open_issues) |
|
522
|
|
|
|
|
523
|
|
|
|
|
524
|
|
|
def get_open_prs_with_milestone(repo) -> list: |
|
525
|
|
|
open_prs = repo.get_pulls(state='open', direction='asc') |
|
526
|
|
|
return get_items_with_milestone(open_prs) |
|
527
|
|
|
|
|
528
|
|
|
|
|
529
|
|
|
def update_milestone(repo, object_list) -> None: |
|
530
|
|
|
milestone = get_latest_version_milestone(repo) |
|
531
|
|
|
for item in object_list: |
|
532
|
|
|
try: |
|
533
|
|
|
item.edit(milestone=milestone) |
|
534
|
|
|
except Exception as e: |
|
535
|
|
|
print(f'Error: {e}') |
|
536
|
|
|
|
|
537
|
|
|
|
|
538
|
|
|
def update_milestone_in_issues(repo, issues_list) -> None: |
|
539
|
|
|
outdate_issues = filter_outdated_items(repo, issues_list) |
|
540
|
|
|
if outdate_issues: |
|
541
|
|
|
update_milestone(repo, outdate_issues) |
|
542
|
|
|
|
|
543
|
|
|
|
|
544
|
|
|
# Repo Stats |
|
545
|
|
|
def collect_release_info(repo) -> dict: |
|
546
|
|
|
data = dict() |
|
547
|
|
|
latest_release = get_latest_release(repo) |
|
548
|
|
|
data["latest_release_created"] = latest_release.created_at |
|
549
|
|
|
data["latest_release_published"] = latest_release.published_at |
|
550
|
|
|
data["latest_release_url"] = latest_release.html_url |
|
551
|
|
|
data["latest_version"] = get_latest_version(repo) |
|
552
|
|
|
|
|
553
|
|
|
next_release_date = get_next_release_date(latest_release.published_at) |
|
554
|
|
|
next_release_remaining_days = next_release_date - datetime.today() |
|
555
|
|
|
data["next_release_date"] = next_release_date |
|
556
|
|
|
data["next_release_remaining_days"] = next_release_remaining_days.days |
|
557
|
|
|
data["next_release_state"] = describe_next_release_state(repo) |
|
558
|
|
|
data["next_release_version"] = get_next_release_version(repo) |
|
559
|
|
|
next_stabilization_date = get_next_stabilization_date(next_release_date) |
|
560
|
|
|
next_stabilization_remaining_days = next_stabilization_date - datetime.today().date() |
|
561
|
|
|
data["next_stabilization_date"] = next_stabilization_date |
|
562
|
|
|
data["next_stabilization_remaining_days"] = next_stabilization_remaining_days.days |
|
563
|
|
|
|
|
564
|
|
|
previous_milestone = get_version_milestone(repo, data["latest_version"]) |
|
565
|
|
|
data["previous_milestone"] = previous_milestone.title |
|
566
|
|
|
data["previous_milestone_closed_issues"] = previous_milestone.closed_issues |
|
567
|
|
|
data["previous_milestone_open_issues"] = previous_milestone.open_issues |
|
568
|
|
|
data["previous_milestone_total_issues"] = data["previous_milestone_closed_issues"]\ |
|
569
|
|
|
+ data["previous_milestone_open_issues"] |
|
570
|
|
|
|
|
571
|
|
|
active_milestone = get_latest_version_milestone(repo) |
|
572
|
|
|
data["active_milestone"] = active_milestone.title |
|
573
|
|
|
data["active_milestone_closed_issues"] = active_milestone.closed_issues |
|
574
|
|
|
data["active_milestone_open_issues"] = active_milestone.open_issues |
|
575
|
|
|
data["active_milestone_total_issues"] = data["active_milestone_closed_issues"]\ |
|
576
|
|
|
+ data["active_milestone_open_issues"] |
|
577
|
|
|
return data |
|
578
|
|
|
|
|
579
|
|
|
|
|
580
|
|
|
def print_specific_stat(status, current, total) -> None: |
|
581
|
|
|
if current > 0: |
|
582
|
|
|
percent = round((current / total) * 100.00, 2) |
|
583
|
|
|
print(f'{status:23.23} {current} / {total} = {percent}%') |
|
584
|
|
|
|
|
585
|
|
|
|
|
586
|
|
|
def print_last_release_stats(data): |
|
587
|
|
|
version = data['latest_version'] |
|
588
|
|
|
created_at = data['latest_release_created'] |
|
589
|
|
|
published_at = data['latest_release_published'] |
|
590
|
|
|
url_download = data['latest_release_url'] |
|
591
|
|
|
closed_issues = data["previous_milestone_closed_issues"] |
|
592
|
|
|
total_issues = data["previous_milestone_total_issues"] |
|
593
|
|
|
|
|
594
|
|
|
print('Information based on the latest published release:') |
|
595
|
|
|
print(f'Current Version: {version}') |
|
596
|
|
|
print(f'Created at: {created_at}') |
|
597
|
|
|
print(f'Published at: {published_at}') |
|
598
|
|
|
print(f'Release Notes: {url_download}') |
|
599
|
|
|
print_specific_stat("Closed Issues/PRs:", closed_issues, total_issues) |
|
600
|
|
|
|
|
601
|
|
|
|
|
602
|
|
|
def print_next_release_stats(data): |
|
603
|
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
604
|
|
|
milestone = data['active_milestone'] |
|
605
|
|
|
next_release_version = data['next_release_version'] |
|
606
|
|
|
next_release_date = data['next_release_date'] |
|
607
|
|
|
next_release_days = data['next_release_remaining_days'] |
|
608
|
|
|
next_release_state = data['next_release_state'] |
|
609
|
|
|
next_stab_date = data['next_stabilization_date'] |
|
610
|
|
|
next_stab_days = data['next_stabilization_remaining_days'] |
|
611
|
|
|
closed_issues = data["active_milestone_closed_issues"] |
|
612
|
|
|
total_issues = data["active_milestone_total_issues"] |
|
613
|
|
|
|
|
614
|
|
|
print(f'\nNext release information in {now}:') |
|
615
|
|
|
print(f'Next Version: {next_release_version}') |
|
616
|
|
|
print(f'Current State: {next_release_state}') |
|
617
|
|
|
print(f'Estimated Release Date: {next_release_date} ({next_release_days} remaining days)') |
|
618
|
|
|
print(f'Stabilization Date: {next_stab_date} ({next_stab_days} remaining days)') |
|
619
|
|
|
print(f'Working Milestone: {milestone}') |
|
620
|
|
|
|
|
621
|
|
|
print_specific_stat("Closed Issues/PRs:", closed_issues, total_issues) |
|
622
|
|
|
|
|
623
|
|
|
|
|
624
|
|
|
# Functions to specific phases of the process |
|
625
|
|
|
def stats(repo, args): |
|
626
|
|
|
try: |
|
627
|
|
|
rel_data = collect_release_info(repo) |
|
628
|
|
|
print_last_release_stats(rel_data) |
|
629
|
|
|
print_next_release_stats(rel_data) |
|
630
|
|
|
except Exception as e: |
|
631
|
|
|
print(f'Error: {e}') |
|
632
|
|
|
|
|
633
|
|
|
|
|
634
|
|
|
def cleanup(repo, args) -> None: |
|
635
|
|
|
if args.branch: |
|
636
|
|
|
branches_to_remove = get_old_stabilization_branch(repo) |
|
637
|
|
|
if branches_to_remove: |
|
638
|
|
|
for branch in branches_to_remove: |
|
639
|
|
|
remove_old_stabilization_branch(repo, branch) |
|
640
|
|
|
else: |
|
641
|
|
|
print('Great! There is no branch to be removed.') |
|
642
|
|
|
|
|
643
|
|
|
|
|
644
|
|
|
def release_prep(repo, args) -> None: |
|
645
|
|
|
if args.contributors: |
|
646
|
|
|
update_contributors() |
|
647
|
|
|
|
|
648
|
|
|
stab_branch_name = get_next_stabilization_branch(repo) |
|
649
|
|
|
if args.branch: |
|
650
|
|
|
print('git checkout master\n' |
|
651
|
|
|
'git pull upstream master\n' |
|
652
|
|
|
f'git checkout -b {stab_branch_name}\n' |
|
653
|
|
|
f'git push -u upstream {stab_branch_name}') |
|
654
|
|
|
|
|
655
|
|
|
if args.milestone: |
|
656
|
|
|
if is_next_release_in_progress(repo): |
|
657
|
|
|
stab_milestone = get_latest_version_milestone(repo) |
|
658
|
|
|
next_release_version = get_next_release_version(repo) |
|
659
|
|
|
new_milestone = bump_version(next_release_version, 'minor') |
|
660
|
|
|
close_milestone(stab_milestone) |
|
661
|
|
|
create_repo_milestone(repo, new_milestone) |
|
662
|
|
|
else: |
|
663
|
|
|
print(f'Milestones can be managed after the "{stab_branch_name}" branch is created.') |
|
664
|
|
|
|
|
665
|
|
|
if args.issues: |
|
666
|
|
|
issues_list = get_open_issues_with_milestone(repo) |
|
667
|
|
|
update_milestone_in_issues(repo, issues_list) |
|
668
|
|
|
|
|
669
|
|
|
if args.bump_version: |
|
670
|
|
|
bump_master_version(repo) |
|
671
|
|
|
|
|
672
|
|
|
|
|
673
|
|
|
def release(repo, args) -> None: |
|
674
|
|
|
if is_next_release_in_progress(repo): |
|
675
|
|
|
if args.tag: |
|
676
|
|
|
next_version = get_next_release_version(repo) |
|
677
|
|
|
tag_name = f'v{next_version}' |
|
678
|
|
|
stab_branch = get_next_stabilization_branch(repo) |
|
679
|
|
|
print('Release process starts when a version tag is added to the branch.\n' |
|
680
|
|
|
'It is better to do it manually. But here are the commands you need:\n') |
|
681
|
|
|
print(f'git tag {tag_name} {stab_branch}\n' |
|
682
|
|
|
'git push --tags') |
|
683
|
|
|
|
|
684
|
|
|
if args.message: |
|
685
|
|
|
message = get_release_start_message(repo) |
|
686
|
|
|
print_communication_channels('release') |
|
687
|
|
|
print(message) |
|
688
|
|
|
else: |
|
689
|
|
|
print('Please, ensure that all steps in the Release Preparation are concluded.') |
|
690
|
|
|
|
|
691
|
|
|
|
|
692
|
|
|
def finish(repo, args) -> None: |
|
693
|
|
|
if not is_next_release_in_progress(repo): |
|
694
|
|
|
if args.message: |
|
695
|
|
|
message = get_release_end_message(repo) |
|
696
|
|
|
print_communication_channels('finish') |
|
697
|
|
|
print(message) |
|
698
|
|
|
else: |
|
699
|
|
|
print('Please, ensure that all steps in the Release process are concluded.') |
|
700
|
|
|
|
|
701
|
|
|
|
|
702
|
|
|
def parse_arguments(): |
|
703
|
|
|
'''Call argparse to process input parameters and return parsed args''' |
|
704
|
|
|
parser = argparse.ArgumentParser( |
|
705
|
|
|
description="Automate Github tasks included in the Release Process.", |
|
706
|
|
|
epilog="Example: release_helper.py -c ~/secrets.ini -r ComplianceAsCode/content stats", |
|
707
|
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter) |
|
708
|
|
|
parser.add_argument( |
|
709
|
|
|
'-c', '--creds-file', default='~/secret.ini', |
|
710
|
|
|
help="INI file containing Github token.") |
|
711
|
|
|
parser.add_argument( |
|
712
|
|
|
'-r', '--repository', action='store', default='ComplianceAsCode/content', |
|
713
|
|
|
help="Project repository name.") |
|
714
|
|
|
|
|
715
|
|
|
subparsers = parser.add_subparsers(dest='subcmd', required=True) |
|
716
|
|
|
subparsers.add_parser( |
|
717
|
|
|
'stats', help="Return information about the repository current state.") |
|
718
|
|
|
|
|
719
|
|
|
cleanup_parser = subparsers.add_parser( |
|
720
|
|
|
'cleanup', help="Cleanup after completing a release.") |
|
721
|
|
|
cleanup_parser.add_argument( |
|
722
|
|
|
'-b', '--branch', action='store_true', |
|
723
|
|
|
help="Remove temporary branch used during stabilization phase.") |
|
724
|
|
|
|
|
725
|
|
|
release_prep_parser = subparsers.add_parser( |
|
726
|
|
|
'release_prep', help="Prepare the repository for the next release.") |
|
727
|
|
|
release_prep_parser.add_argument( |
|
728
|
|
|
'-c', '--contributors', action='store_true', |
|
729
|
|
|
help="Update the contributors lists. Do it before creating the stabilization branch.") |
|
730
|
|
|
release_prep_parser.add_argument( |
|
731
|
|
|
'-b', '--branch', action='store_true', |
|
732
|
|
|
help="Create the stabilization branch for the next release.") |
|
733
|
|
|
release_prep_parser.add_argument( |
|
734
|
|
|
'-m', '--milestone', action='store_true', |
|
735
|
|
|
help="Create the next milestone and close the current milestone.") |
|
736
|
|
|
release_prep_parser.add_argument( |
|
737
|
|
|
'-i', '--issues', action='store_true', |
|
738
|
|
|
help="Move Open Issues and PRs with a milestone to the next milestone.") |
|
739
|
|
|
release_prep_parser.add_argument( |
|
740
|
|
|
'-v', '--bump-version', action='store_true', |
|
741
|
|
|
help="Bump the project version in *master* branch.") |
|
742
|
|
|
|
|
743
|
|
|
release_parser = subparsers.add_parser( |
|
744
|
|
|
'release', help="Tasks to be done during the stabilization phase.") |
|
745
|
|
|
release_parser.add_argument( |
|
746
|
|
|
'-m', '--message', action='store_true', |
|
747
|
|
|
help="Prepare a message to communicate the stabilization process has started.") |
|
748
|
|
|
release_parser.add_argument( |
|
749
|
|
|
'-t', '--tag', action='store_true', |
|
750
|
|
|
help="Show commands to properly tag the branch and start the release.") |
|
751
|
|
|
|
|
752
|
|
|
finish_parser = subparsers.add_parser( |
|
753
|
|
|
'finish', help="Tasks to be done when the release is out.") |
|
754
|
|
|
finish_parser.add_argument( |
|
755
|
|
|
'-m', '--message', action='store_true', |
|
756
|
|
|
help="Prepare a message to communicate the release is out.") |
|
757
|
|
|
return parser.parse_args() |
|
758
|
|
|
|
|
759
|
|
|
|
|
760
|
|
|
def main(): |
|
761
|
|
|
subcmds = dict( |
|
762
|
|
|
stats=stats, |
|
763
|
|
|
cleanup=cleanup, |
|
764
|
|
|
release_prep=release_prep, |
|
765
|
|
|
release=release, |
|
766
|
|
|
finish=finish) |
|
767
|
|
|
|
|
768
|
|
|
args = parse_arguments() |
|
769
|
|
|
|
|
770
|
|
|
try: |
|
771
|
|
|
ghs = create_github_session(args.creds_file) |
|
772
|
|
|
repo = get_repo_object(ghs, args.repository) |
|
773
|
|
|
except Exception as e: |
|
774
|
|
|
print(f'Error when getting the repository {args.repository}: {e}') |
|
775
|
|
|
exit(1) |
|
776
|
|
|
|
|
777
|
|
|
subcmds[args.subcmd](repo, args) |
|
|
|
|
|
|
778
|
|
|
|
|
779
|
|
|
|
|
780
|
|
|
if __name__ == "__main__": |
|
781
|
|
|
main() |
|
782
|
|
|
|