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
|
|
|
|