1#Copyright 2019 gRPC authors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Generate draft and release notes in Markdown from Github PRs.
15
16You'll need a github API token to avoid being rate-limited. See
17https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/
18
19This script collects PRs using "git log X..Y" from local repo where X and Y are
20tags or release branch names of previous and current releases respectively.
21Typically, notes are generated before the release branch is labelled so Y is
22almost always the name of the release branch. X is the previous release branch
23if this is not a patch release. Otherwise, it is the previous release tag.
24For example, for release v1.17.0, X will be origin/v1.16.x and for release v1.17.3,
25X will be v1.17.2. In both cases Y will be origin/v1.17.x.
26
27"""
28
29from collections import defaultdict
30import json
31import logging
32
33import urllib3
34
35logging.basicConfig(level=logging.WARNING)
36
37content_header = """Draft Release Notes For {version}
38--
39Final release notes will be generated from the PR titles that have *"release notes:yes"* label. If you have any additional notes please add them below. These will be appended to auto generated release notes. Previous release notes are [here](https://github.com/grpc/grpc/releases).
40
41**Also, look at the PRs listed below against your name.** Please apply the missing labels and make necessary corrections (like fixing the title) to the PR in Github. Final release notes will be generated just before the release on {date}.
42
43Add additional notes not in PRs
44--
45
46Core
47-
48
49
50C++
51-
52
53
54C#
55-
56
57
58Objective-C
59-
60
61
62PHP
63-
64
65
66Python
67-
68
69
70Ruby
71-
72
73
74"""
75
76rl_header = """This is release {version} ([{name}](https://github.com/grpc/grpc/blob/master/doc/g_stands_for.md)) of gRPC Core.
77
78For gRPC documentation, see [grpc.io](https://grpc.io/). For previous releases, see [Releases](https://github.com/grpc/grpc/releases).
79
80This release contains refinements, improvements, and bug fixes, with highlights listed below.
81
82
83"""
84
85HTML_URL = "https://github.com/grpc/grpc/pull/"
86API_URL = 'https://api.github.com/repos/grpc/grpc/pulls/'
87
88
89def get_commit_log(prevRelLabel, relBranch):
90    """Return the output of 'git log prevRelLabel..relBranch' """
91
92    import subprocess
93    glg_command = [
94        "git", "log", "--pretty=oneline", "--committer=GitHub",
95        "%s..%s" % (prevRelLabel, relBranch)
96    ]
97    print(("Running ", " ".join(glg_command)))
98    return subprocess.check_output(glg_command).decode('utf-8', 'ignore')
99
100
101def get_pr_data(pr_num):
102    """Get the PR data from github. Return 'error' on exception"""
103    http = urllib3.PoolManager(retries=urllib3.Retry(total=7, backoff_factor=1),
104                               timeout=4.0)
105    url = API_URL + pr_num
106    try:
107        response = http.request('GET',
108                                url,
109                                headers={'Authorization': 'token %s' % TOKEN})
110    except urllib3.exceptions.HTTPError as e:
111        print('Request error:', e.reason)
112        return 'error'
113    return json.loads(response.data.decode('utf-8'))
114
115
116def get_pr_titles(gitLogs):
117    import re
118    error_count = 0
119    # PRs with merge commits
120    match_merge_pr = "Merge pull request #(\d+)"
121    prlist_merge_pr = re.findall(match_merge_pr, gitLogs, re.MULTILINE)
122    print("\nPRs matching 'Merge pull request #<num>':")
123    print(prlist_merge_pr)
124    print("\n")
125    # PRs using Github's squash & merge feature
126    match_sq = "\(#(\d+)\)$"
127    prlist_sq = re.findall(match_sq, gitLogs, re.MULTILINE)
128    print("\nPRs matching '[PR Description](#<num>)$'")
129    print(prlist_sq)
130    print("\n")
131    prlist = prlist_merge_pr + prlist_sq
132    langs_pr = defaultdict(list)
133    for pr_num in prlist:
134        pr_num = str(pr_num)
135        print(("---------- getting data for PR " + pr_num))
136        pr = get_pr_data(pr_num)
137        if pr == "error":
138            print(
139                ("\n***ERROR*** Error in getting data for PR " + pr_num + "\n"))
140            error_count += 1
141            continue
142        rl_no_found = False
143        rl_yes_found = False
144        lang_found = False
145        for label in pr['labels']:
146            if label['name'] == 'release notes: yes':
147                rl_yes_found = True
148            elif label['name'] == 'release notes: no':
149                rl_no_found = True
150            elif label['name'].startswith('lang/'):
151                lang_found = True
152                lang = label['name'].split('/')[1].lower()
153                #lang = lang[0].upper() + lang[1:]
154        body = pr["title"]
155        if not body.endswith("."):
156            body = body + "."
157        if not pr["merged_by"]:
158            print(("\n***ERROR***: No merge_by found for PR " + pr_num + "\n"))
159            error_count += 1
160            continue
161
162        prline = "-  " + body + " ([#" + pr_num + "](" + HTML_URL + pr_num + "))"
163        detail = "- " + pr["merged_by"]["login"] + "@ " + prline
164        print(detail)
165        #if no RL label
166        if not rl_no_found and not rl_yes_found:
167            print(("Release notes label missing for " + pr_num))
168            langs_pr["nolabel"].append(detail)
169        elif rl_yes_found and not lang_found:
170            print(("Lang label missing for " + pr_num))
171            langs_pr["nolang"].append(detail)
172        elif rl_no_found:
173            print(("'Release notes:no' found for " + pr_num))
174            langs_pr["notinrel"].append(detail)
175        elif rl_yes_found:
176            print(("'Release notes:yes' found for " + pr_num + " with lang " +
177                   lang))
178            langs_pr["inrel"].append(detail)
179            langs_pr[lang].append(prline)
180
181    return langs_pr, error_count
182
183
184def write_draft(langs_pr, file, version, date):
185    file.write(content_header.format(version=version, date=date))
186    file.write("PRs with missing release notes label - please fix in Github\n")
187    file.write("---\n")
188    file.write("\n")
189    if langs_pr["nolabel"]:
190        langs_pr["nolabel"].sort()
191        file.write("\n".join(langs_pr["nolabel"]))
192    else:
193        file.write("- None")
194    file.write("\n")
195    file.write("\n")
196    file.write("PRs with missing lang label - please fix in Github\n")
197    file.write("---\n")
198    file.write("\n")
199    if langs_pr["nolang"]:
200        langs_pr["nolang"].sort()
201        file.write("\n".join(langs_pr["nolang"]))
202    else:
203        file.write("- None")
204    file.write("\n")
205    file.write("\n")
206    file.write(
207        "PRs going into release notes - please check title and fix in Github. Do not edit here.\n"
208    )
209    file.write("---\n")
210    file.write("\n")
211    if langs_pr["inrel"]:
212        langs_pr["inrel"].sort()
213        file.write("\n".join(langs_pr["inrel"]))
214    else:
215        file.write("- None")
216    file.write("\n")
217    file.write("\n")
218    file.write("PRs not going into release notes\n")
219    file.write("---\n")
220    file.write("\n")
221    if langs_pr["notinrel"]:
222        langs_pr["notinrel"].sort()
223        file.write("\n".join(langs_pr["notinrel"]))
224    else:
225        file.write("- None")
226    file.write("\n")
227    file.write("\n")
228
229
230def write_rel_notes(langs_pr, file, version, name):
231    file.write(rl_header.format(version=version, name=name))
232    if langs_pr["core"]:
233        file.write("Core\n---\n\n")
234        file.write("\n".join(langs_pr["core"]))
235        file.write("\n")
236        file.write("\n")
237    if langs_pr["c++"]:
238        file.write("C++\n---\n\n")
239        file.write("\n".join(langs_pr["c++"]))
240        file.write("\n")
241        file.write("\n")
242    if langs_pr["c#"]:
243        file.write("C#\n---\n\n")
244        file.write("\n".join(langs_pr["c#"]))
245        file.write("\n")
246        file.write("\n")
247    if langs_pr["go"]:
248        file.write("Go\n---\n\n")
249        file.write("\n".join(langs_pr["go"]))
250        file.write("\n")
251        file.write("\n")
252    if langs_pr["Java"]:
253        file.write("Java\n---\n\n")
254        file.write("\n".join(langs_pr["Java"]))
255        file.write("\n")
256        file.write("\n")
257    if langs_pr["node"]:
258        file.write("Node\n---\n\n")
259        file.write("\n".join(langs_pr["node"]))
260        file.write("\n")
261        file.write("\n")
262    if langs_pr["objc"]:
263        file.write("Objective-C\n---\n\n")
264        file.write("\n".join(langs_pr["objc"]))
265        file.write("\n")
266        file.write("\n")
267    if langs_pr["php"]:
268        file.write("PHP\n---\n\n")
269        file.write("\n".join(langs_pr["php"]))
270        file.write("\n")
271        file.write("\n")
272    if langs_pr["python"]:
273        file.write("Python\n---\n\n")
274        file.write("\n".join(langs_pr["python"]))
275        file.write("\n")
276        file.write("\n")
277    if langs_pr["ruby"]:
278        file.write("Ruby\n---\n\n")
279        file.write("\n".join(langs_pr["ruby"]))
280        file.write("\n")
281        file.write("\n")
282    if langs_pr["other"]:
283        file.write("Other\n---\n\n")
284        file.write("\n".join(langs_pr["other"]))
285        file.write("\n")
286        file.write("\n")
287
288
289def build_args_parser():
290    import argparse
291    parser = argparse.ArgumentParser()
292    parser.add_argument('release_version',
293                        type=str,
294                        help='New release version e.g. 1.14.0')
295    parser.add_argument('release_name',
296                        type=str,
297                        help='New release name e.g. gladiolus')
298    parser.add_argument('release_date',
299                        type=str,
300                        help='Release date e.g. 7/30/18')
301    parser.add_argument('previous_release_label',
302                        type=str,
303                        help='Previous release branch/tag e.g. v1.13.x')
304    parser.add_argument('release_branch',
305                        type=str,
306                        help='Current release branch e.g. origin/v1.14.x')
307    parser.add_argument('draft_filename',
308                        type=str,
309                        help='Name of the draft file e.g. draft.md')
310    parser.add_argument('release_notes_filename',
311                        type=str,
312                        help='Name of the release notes file e.g. relnotes.md')
313    parser.add_argument('--token',
314                        type=str,
315                        default='',
316                        help='GitHub API token to avoid being rate limited')
317    return parser
318
319
320def main():
321    import os
322    global TOKEN
323
324    parser = build_args_parser()
325    args = parser.parse_args()
326    version, name, date = args.release_version, args.release_name, args.release_date
327    start, end = args.previous_release_label, args.release_branch
328
329    TOKEN = args.token
330    if TOKEN == '':
331        try:
332            TOKEN = os.environ["GITHUB_TOKEN"]
333        except:
334            pass
335    if TOKEN == '':
336        print(
337            "Error: Github API token required. Either include param --token=<your github token> or set environment variable GITHUB_TOKEN to your github token"
338        )
339        return
340
341    langs_pr, error_count = get_pr_titles(get_commit_log(start, end))
342
343    draft_file, rel_file = args.draft_filename, args.release_notes_filename
344    filename = os.path.abspath(draft_file)
345    if os.path.exists(filename):
346        file = open(filename, 'r+')
347    else:
348        file = open(filename, 'w')
349
350    file.seek(0)
351    write_draft(langs_pr, file, version, date)
352    file.truncate()
353    file.close()
354    print(("\nDraft notes written to " + filename))
355
356    filename = os.path.abspath(rel_file)
357    if os.path.exists(filename):
358        file = open(filename, 'r+')
359    else:
360        file = open(filename, 'w')
361
362    file.seek(0)
363    write_rel_notes(langs_pr, file, version, name)
364    file.truncate()
365    file.close()
366    print(("\nRelease notes written to " + filename))
367    if error_count > 0:
368        print("\n\n*** Errors were encountered. See log. *********\n")
369
370
371if __name__ == "__main__":
372    main()
373