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) |
||
0 ignored issues
–
show
introduced
by
![]() |
|||
778 | |||
779 | |||
780 | if __name__ == "__main__": |
||
781 | main() |
||
782 |