1"""Generate the release notes automatically from Github pull requests. 2 3Start with: 4``` 5export GH_TOKEN=<your-gh-api-token> 6``` 7 8Then, for a major release: 9``` 10python /path/to/generate_release_notes.py v0.14.0 main --version 0.15.0 11``` 12 13For a minor release: 14``` 15python /path/to/generate_release_notes.py v.14.2 v0.14.x --version 0.14.3 16``` 17 18You should probably redirect the output with: 19``` 20python /path/to/generate_release_notes.py [args] | tee release_notes.rst 21``` 22 23You'll require PyGitHub and tqdm, which you can install with: 24``` 25pip install -r requirements/_release_tools.txt 26``` 27 28References 29https://github.com/scikit-image/scikit-image/issues/3404 30https://github.com/scikit-image/scikit-image/issues/3405 31""" 32import os 33import argparse 34from datetime import datetime 35from collections import OrderedDict 36import string 37from warnings import warn 38 39from github import Github 40try: 41 from tqdm import tqdm 42except ImportError: 43 from warnings import warn 44 warn('tqdm not installed. This script takes approximately 5 minutes ' 45 'to run. To view live progressbars, please install tqdm. ' 46 'Otherwise, be patient.') 47 def tqdm(i, **kwargs): 48 return i 49 50 51GH_USER = 'scikit-image' 52GH_REPO = 'scikit-image' 53GH_TOKEN = os.environ.get('GH_TOKEN') 54if GH_TOKEN is None: 55 raise RuntimeError( 56 "It is necessary that the environment variable `GH_TOKEN` " 57 "be set to avoid running into problems with rate limiting. " 58 "One can be acquired at https://github.com/settings/tokens.\n\n" 59 "You do not need to select any permission boxes while generating " 60 "the token.") 61 62g = Github(GH_TOKEN) 63repository = g.get_repo(f'{GH_USER}/{GH_REPO}') 64 65 66parser = argparse.ArgumentParser(usage=__doc__) 67parser.add_argument('from_commit', help='The starting tag.') 68parser.add_argument('to_commit', help='The head branch.') 69parser.add_argument('--version', help="Version you're about to release.", 70 default='0.15.0') 71 72args = parser.parse_args() 73 74for tag in repository.get_tags(): 75 if tag.name == args.from_commit: 76 previous_tag = tag 77 break 78else: 79 raise RuntimeError(f'Desired tag ({args.from_commit}) not found') 80 81# For some reason, go get the github commit from the commit to get 82# the correct date 83github_commit = previous_tag.commit.commit 84previous_tag_date = datetime.strptime(github_commit.last_modified, 85 '%a, %d %b %Y %H:%M:%S %Z') 86 87 88all_commits = list(tqdm(repository.get_commits(sha=args.to_commit, 89 since=previous_tag_date), 90 desc=f'Getting all commits between {args.from_commit} ' 91 f'and {args.to_commit}')) 92all_hashes = set(c.sha for c in all_commits) 93 94authors = set() 95reviewers = set() 96committers = set() 97users = dict() # keep track of known usernames 98 99def find_author_info(commit): 100 """Return committer and author of a commit. 101 102 Parameters 103 ---------- 104 commit : Github commit 105 The commit to query. 106 107 Returns 108 ------- 109 committer : str or None 110 The git committer. 111 author : str 112 The git author. 113 """ 114 committer = None 115 if commit.committer is not None: 116 committer = commit.committer.name or commit.committer.login 117 git_author = commit.raw_data['commit']['author']['name'] 118 if commit.author is not None: 119 author = commit.author.name or commit.author.login + f' ({git_author})' 120 else: 121 # Users that deleted their accounts will appear as None 122 author = git_author 123 return committer, author 124 125 126def add_to_users(users, new_user): 127 if new_user.name is None: 128 users[new_user.login] = new_user.login 129 else: 130 users[new_user.login] = new_user.name 131 132for commit in tqdm(all_commits, desc='Getting commiters and authors'): 133 committer, author = find_author_info(commit) 134 if committer is not None: 135 committers.add(committer) 136 # users maps github ids to a unique name. 137 add_to_users(users, commit.committer) 138 committers.add(users[commit.committer.login]) 139 140 if commit.author is not None: 141 add_to_users(users, commit.author) 142 authors.add(author) 143 144# this gets found as a commiter 145committers.discard('GitHub Web Flow') 146authors.discard('Azure Pipelines Bot') 147 148highlights = OrderedDict() 149 150highlights['New Feature'] = {} 151highlights['Improvement'] = {} 152highlights['Bugfix'] = {} 153highlights['API Change'] = {} 154highlights['Deprecations'] = {} 155highlights['Build Tool'] = {} 156other_pull_requests = {} 157 158for pull in tqdm(g.search_issues(f'repo:{GH_USER}/{GH_REPO} ' 159 f'merged:>{previous_tag_date.isoformat()} ' 160 'sort:created-asc'), 161 desc='Pull Requests...'): 162 pr = repository.get_pull(pull.number) 163 if pr.merge_commit_sha in all_hashes: 164 summary = pull.title 165 for review in pr.get_reviews(): 166 if review.user.login not in users: 167 users[review.user.login] = review.user.name 168 reviewers.add(users[review.user.login]) 169 for key, key_dict in highlights.items(): 170 pr_title_prefix = (key + ': ').lower() 171 if summary.lower().startswith(pr_title_prefix): 172 key_dict[pull.number] = { 173 'summary': summary[len(pr_title_prefix):] 174 } 175 break 176 else: 177 other_pull_requests[pull.number] = { 178 'summary': summary 179 } 180 181 182# add Other PRs to the ordered dict to make doc generation easier. 183highlights['Other Pull Request'] = other_pull_requests 184 185 186# Now generate the release notes 187announcement_title = f'Announcement: scikit-image {args.version}' 188print(announcement_title) 189print('=' * len(announcement_title)) 190 191print(f''' 192We're happy to announce the release of scikit-image v{args.version}! 193 194scikit-image is an image processing toolbox for SciPy that includes algorithms 195for segmentation, geometric transformations, color space manipulation, 196analysis, filtering, morphology, feature detection, and more. 197''') 198 199print(""" 200For more information, examples, and documentation, please visit our website: 201 202https://scikit-image.org 203 204""" 205) 206 207for section, pull_request_dicts in highlights.items(): 208 if not pull_request_dicts: 209 continue 210 print(f'{section}s\n{"*" * (len(section)+1)}') 211 for number, pull_request_info in pull_request_dicts.items(): 212 print(f'- {pull_request_info["summary"]} (#{number})') 213 214 215contributors = OrderedDict() 216 217contributors['authors'] = authors 218# contributors['committers'] = committers 219contributors['reviewers'] = reviewers 220 221for section_name, contributor_set in contributors.items(): 222 print() 223 committer_str = (f'{len(contributor_set)} {section_name} added to this ' 224 'release [alphabetical by first name or login]') 225 print(committer_str) 226 print('-' * len(committer_str)) 227 228 # Remove None from contributor set if it's in there. 229 if None in contributor_set: 230 contributor_set.remove(None) 231 232 for c in sorted(contributor_set, key=str.lower): 233 print(f'- {c}') 234 print() 235