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 base64
31import json
32
33content_header = """Draft Release Notes For {version}
34--
35Final 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 releases notes are [here](https://github.com/grpc/grpc/releases).
36
37**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}.
38
39Add additional notes not in PRs
40--
41
42Core
43-
44
45
46C++
47-
48
49
50C#
51-
52
53
54Objective-C
55-
56
57
58PHP
59-
60
61
62Python
63-
64
65
66Ruby
67-
68
69
70"""
71
72rl_header = """This is release {version} ([{name}](https://github.com/grpc/grpc/blob/master/doc/g_stands_for.md)) of gRPC Core.
73
74For gRPC documentation, see [grpc.io](https://grpc.io/). For previous releases, see [Releases](https://github.com/grpc/grpc/releases).
75
76This release contains refinements, improvements, and bug fixes, with highlights listed below.
77
78
79"""
80
81HTML_URL = "https://github.com/grpc/grpc/pull/"
82API_URL = 'https://api.github.com/repos/grpc/grpc/pulls/'
83
84
85def get_commit_log(prevRelLabel, relBranch):
86    """Return the output of 'git log --pretty=online --merges prevRelLabel..relBranch' """
87
88    import subprocess
89    print("Running git log --pretty=oneline --merges " + prevRelLabel + ".." +
90          relBranch)
91    return subprocess.check_output([
92        "git", "log", "--pretty=oneline", "--merges",
93        "%s..%s" % (prevRelLabel, relBranch)
94    ])
95
96
97def get_pr_data(pr_num):
98    """Get the PR data from github. Return 'error' on exception"""
99
100    try:
101        from urllib2 import Request, urlopen, HTTPError
102    except ImportError:
103        import urllib
104        from urllib.request import Request, urlopen, HTTPError
105    url = API_URL + pr_num
106    req = Request(url)
107    req.add_header('Authorization', 'token %s' % TOKEN)
108    try:
109        f = urlopen(req)
110        response = json.loads(f.read().decode('utf-8'))
111        #print(response)
112    except HTTPError as e:
113        response = json.loads(e.fp.read().decode('utf-8'))
114        if 'message' in response:
115            print(response['message'])
116        response = "error"
117    return response
118
119
120def get_pr_titles(gitLogs):
121    import re
122    error_count = 0
123    match = b"Merge pull request #(\d+)"
124    prlist = re.findall(match, gitLogs, re.MULTILINE)
125    print("\nPRs matching 'Merge pull request #<num>':")
126    print(prlist)
127    print("\n")
128    langs_pr = defaultdict(list)
129    for pr_num in prlist:
130        pr_num = str(pr_num)
131        print("---------- getting data for PR " + pr_num)
132        pr = get_pr_data(pr_num)
133        if pr == "error":
134            print("\n***ERROR*** Error in getting data for PR " + pr_num + "\n")
135            error_count += 1
136            continue
137        rl_no_found = False
138        rl_yes_found = False
139        lang_found = False
140        for label in pr['labels']:
141            if label['name'] == 'release notes: yes':
142                rl_yes_found = True
143            elif label['name'] == 'release notes: no':
144                rl_no_found = True
145            elif label['name'].startswith('lang/'):
146                lang_found = True
147                lang = label['name'].split('/')[1].lower()
148                #lang = lang[0].upper() + lang[1:]
149        body = pr["title"]
150        if not body.endswith("."):
151            body = body + "."
152        if not pr["merged_by"]:
153            print("\n***ERROR***: No merge_by found for PR " + pr_num + "\n")
154            error_count += 1
155            continue
156
157        prline = "-  " + body + " ([#" + pr_num + "](" + HTML_URL + pr_num + "))"
158        detail = "- " + pr["merged_by"]["login"] + "@ " + prline
159        prline = prline.encode('ascii', 'ignore')
160        detail = detail.encode('ascii', 'ignore')
161        print(detail)
162        #if no RL label
163        if not rl_no_found and not rl_yes_found:
164            print("Release notes label missing for " + pr_num)
165            langs_pr["nolabel"].append(detail)
166        elif rl_yes_found and not lang_found:
167            print("Lang label missing for " + pr_num)
168            langs_pr["nolang"].append(detail)
169        elif rl_no_found:
170            print("'Release notes:no' found for " + pr_num)
171            langs_pr["notinrel"].append(detail)
172        elif rl_yes_found:
173            print("'Release notes:yes' found for " + pr_num + " with lang " +
174                  lang)
175            langs_pr["inrel"].append(detail)
176            langs_pr[lang].append(prline)
177
178    return langs_pr, error_count
179
180
181def write_draft(langs_pr, file, version, date):
182    file.write(content_header.format(version=version, date=date))
183    file.write("PRs with missing release notes label - please fix in Github\n")
184    file.write("---\n")
185    file.write("\n")
186    if langs_pr["nolabel"]:
187        langs_pr["nolabel"].sort()
188        file.write("\n".join(langs_pr["nolabel"]))
189    else:
190        file.write("- None")
191    file.write("\n")
192    file.write("\n")
193    file.write("PRs with missing lang label - please fix in Github\n")
194    file.write("---\n")
195    file.write("\n")
196    if langs_pr["nolang"]:
197        langs_pr["nolang"].sort()
198        file.write("\n".join(langs_pr["nolang"]))
199    else:
200        file.write("- None")
201    file.write("\n")
202    file.write("\n")
203    file.write(
204        "PRs going into release notes - please check title and fix in Github. Do not edit here.\n"
205    )
206    file.write("---\n")
207    file.write("\n")
208    if langs_pr["inrel"]:
209        langs_pr["inrel"].sort()
210        file.write("\n".join(langs_pr["inrel"]))
211    else:
212        file.write("- None")
213    file.write("\n")
214    file.write("\n")
215    file.write("PRs not going into release notes\n")
216    file.write("---\n")
217    file.write("\n")
218    if langs_pr["notinrel"]:
219        langs_pr["notinrel"].sort()
220        file.write("\n".join(langs_pr["notinrel"]))
221    else:
222        file.write("- None")
223    file.write("\n")
224    file.write("\n")
225
226
227def write_rel_notes(langs_pr, file, version, name):
228    file.write(rl_header.format(version=version, name=name))
229    if langs_pr["core"]:
230        file.write("Core\n---\n\n")
231        file.write("\n".join(langs_pr["core"]))
232        file.write("\n")
233        file.write("\n")
234    if langs_pr["c++"]:
235        file.write("C++\n---\n\n")
236        file.write("\n".join(langs_pr["c++"]))
237        file.write("\n")
238        file.write("\n")
239    if langs_pr["c#"]:
240        file.write("C#\n---\n\n")
241        file.write("\n".join(langs_pr["c#"]))
242        file.write("\n")
243        file.write("\n")
244    if langs_pr["go"]:
245        file.write("Go\n---\n\n")
246        file.write("\n".join(langs_pr["go"]))
247        file.write("\n")
248        file.write("\n")
249    if langs_pr["Java"]:
250        file.write("Java\n---\n\n")
251        file.write("\n".join(langs_pr["Java"]))
252        file.write("\n")
253        file.write("\n")
254    if langs_pr["node"]:
255        file.write("Node\n---\n\n")
256        file.write("\n".join(langs_pr["node"]))
257        file.write("\n")
258        file.write("\n")
259    if langs_pr["objc"]:
260        file.write("Objective-C\n---\n\n")
261        file.write("\n".join(langs_pr["objc"]))
262        file.write("\n")
263        file.write("\n")
264    if langs_pr["php"]:
265        file.write("PHP\n---\n\n")
266        file.write("\n".join(langs_pr["php"]))
267        file.write("\n")
268        file.write("\n")
269    if langs_pr["python"]:
270        file.write("Python\n---\n\n")
271        file.write("\n".join(langs_pr["python"]))
272        file.write("\n")
273        file.write("\n")
274    if langs_pr["ruby"]:
275        file.write("Ruby\n---\n\n")
276        file.write("\n".join(langs_pr["ruby"]))
277        file.write("\n")
278        file.write("\n")
279    if langs_pr["other"]:
280        file.write("Other\n---\n\n")
281        file.write("\n".join(langs_pr["other"]))
282        file.write("\n")
283        file.write("\n")
284
285
286def build_args_parser():
287    import argparse
288    parser = argparse.ArgumentParser()
289    parser.add_argument('release_version',
290                        type=str,
291                        help='New release version e.g. 1.14.0')
292    parser.add_argument('release_name',
293                        type=str,
294                        help='New release name e.g. gladiolus')
295    parser.add_argument('release_date',
296                        type=str,
297                        help='Release date e.g. 7/30/18')
298    parser.add_argument('previous_release_label',
299                        type=str,
300                        help='Previous release branch/tag e.g. v1.13.x')
301    parser.add_argument('release_branch',
302                        type=str,
303                        help='Current release branch e.g. origin/v1.14.x')
304    parser.add_argument('draft_filename',
305                        type=str,
306                        help='Name of the draft file e.g. draft.md')
307    parser.add_argument('release_notes_filename',
308                        type=str,
309                        help='Name of the release notes file e.g. relnotes.md')
310    parser.add_argument('--token',
311                        type=str,
312                        default='',
313                        help='GitHub API token to avoid being rate limited')
314    return parser
315
316
317def main():
318    import os
319    global TOKEN
320
321    parser = build_args_parser()
322    args = parser.parse_args()
323    version, name, date = args.release_version, args.release_name, args.release_date
324    start, end = args.previous_release_label, args.release_branch
325
326    TOKEN = args.token
327    if TOKEN == '':
328        try:
329            TOKEN = os.environ["GITHUB_TOKEN"]
330        except:
331            pass
332    if TOKEN == '':
333        print(
334            "Error: Github API token required. Either include param --token=<your github token> or set environment variable GITHUB_TOKEN to your github token"
335        )
336        return
337
338    langs_pr, error_count = get_pr_titles(get_commit_log(start, end))
339
340    draft_file, rel_file = args.draft_filename, args.release_notes_filename
341    filename = os.path.abspath(draft_file)
342    if os.path.exists(filename):
343        file = open(filename, 'r+')
344    else:
345        file = open(filename, 'w')
346
347    file.seek(0)
348    write_draft(langs_pr, file, version, date)
349    file.truncate()
350    file.close()
351    print("\nDraft notes written to " + filename)
352
353    filename = os.path.abspath(rel_file)
354    if os.path.exists(filename):
355        file = open(filename, 'r+')
356    else:
357        file = open(filename, 'w')
358
359    file.seek(0)
360    write_rel_notes(langs_pr, file, version, name)
361    file.truncate()
362    file.close()
363    print("\nRelease notes written to " + filename)
364    if error_count > 0:
365        print("\n\n*** Errors were encountered. See log. *********\n")
366
367
368if __name__ == "__main__":
369    main()
370