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