xref: /aosp_15_r20/external/grpc-grpc/tools/release/release_notes.py (revision cc02d7e222339f7a4f6ba5f422e6413f4bd931f2)
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
32import re
33import subprocess
34
35import urllib3
36
37logging.basicConfig(level=logging.WARNING)
38
39content_header = """Draft Release Notes For {version}
40--
41Final 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).
42
43**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}.
44
45Add additional notes not in PRs
46--
47
48Core
49-
50
51
52C++
53-
54
55
56C#
57-
58
59
60Objective-C
61-
62
63
64PHP
65-
66
67
68Python
69-
70
71
72Ruby
73-
74
75
76"""
77
78rl_header = """This is release {version} ([{name}](https://github.com/grpc/grpc/blob/master/doc/g_stands_for.md)) of gRPC Core.
79
80For gRPC documentation, see [grpc.io](https://grpc.io/). For previous releases, see [Releases](https://github.com/grpc/grpc/releases).
81
82This release contains refinements, improvements, and bug fixes, with highlights listed below.
83
84
85"""
86
87HTML_URL = "https://github.com/grpc/grpc/pull/"
88API_URL = "https://api.github.com/repos/grpc/grpc/pulls/"
89
90
91def get_commit_detail(commit):
92    """Print commit and CL info for the commits that are submitted with CL-first workflow and warn the release manager to check manually."""
93    glg_command = [
94        "git",
95        "log",
96        "-n 1",
97        "%s" % commit,
98    ]
99    output = subprocess.check_output(glg_command).decode("utf-8", "ignore")
100    matches = re.search("Author:.*<(.*@).*>", output)
101    author = matches.group(1)
102    detail = "- " + author + " "
103    title = output.splitlines()[4].strip()
104    detail += "- " + title
105    if not title.endswith("."):
106        detail += "."
107    matches = re.search("PiperOrigin-RevId: ([0-9]+)$", output)
108    cl_num = matches.group(1)
109    detail += (
110        " ([commit](https://github.com/grpc/grpc/commit/"
111        + commit
112        + ")) ([CL](https://critique.corp.google.com/cl/"
113        + cl_num
114        + "))"
115    )
116    return detail
117
118
119def get_commit_log(prevRelLabel, relBranch):
120    """Return the output of 'git log prevRelLabel..relBranch'"""
121
122    import subprocess
123
124    glg_command = [
125        "git",
126        "log",
127        "--pretty=oneline",
128        "%s..%s" % (prevRelLabel, relBranch),
129    ]
130    print(("Running ", " ".join(glg_command)))
131    return subprocess.check_output(glg_command).decode("utf-8", "ignore")
132
133
134def get_pr_data(pr_num):
135    """Get the PR data from github. Return 'error' on exception"""
136    http = urllib3.PoolManager(
137        retries=urllib3.Retry(total=7, backoff_factor=1), timeout=4.0
138    )
139    url = API_URL + pr_num
140    try:
141        response = http.request(
142            "GET", url, headers={"Authorization": "token %s" % TOKEN}
143        )
144    except urllib3.exceptions.HTTPError as e:
145        print("Request error:", e.reason)
146        return "error"
147    return json.loads(response.data.decode("utf-8"))
148
149
150def get_pr_titles(gitLogs):
151    import re
152
153    # All commits
154    match_commit = "^([a-fA-F0-9]+) "
155    all_commits_set = set(re.findall(match_commit, gitLogs, re.MULTILINE))
156
157    error_count = 0
158    # PRs with merge commits
159    match_merge_pr = "^([a-fA-F0-9]+) .*Merge pull request #(\d+)"
160    matches = re.findall(match_merge_pr, gitLogs, re.MULTILINE)
161    merge_commits = []
162    prlist_merge_pr = []
163    if matches:
164        merge_commits, prlist_merge_pr = zip(*matches)
165    merge_commits_set = set(merge_commits)
166    print("\nPRs matching 'Merge pull request #<num>':")
167    print(prlist_merge_pr)
168    print("\n")
169
170    # PRs using Github's squash & merge feature
171    match_sq = "^([a-fA-F0-9]+) .*\(#(\d+)\)$"
172    matches = re.findall(match_sq, gitLogs, re.MULTILINE)
173    if matches:
174        sq_commits, prlist_sq = zip(*matches)
175    sq_commits_set = set(sq_commits)
176    print("\nPRs matching '[PR Description](#<num>)$'")
177    print(prlist_sq)
178    print("\n")
179    prlist = list(prlist_merge_pr) + list(prlist_sq)
180    langs_pr = defaultdict(list)
181    for pr_num in prlist:
182        pr_num = str(pr_num)
183        print(("---------- getting data for PR " + pr_num))
184        pr = get_pr_data(pr_num)
185        if pr == "error":
186            print(
187                ("\n***ERROR*** Error in getting data for PR " + pr_num + "\n")
188            )
189            error_count += 1
190            continue
191        rl_no_found = False
192        rl_yes_found = False
193        lang_found = False
194        for label in pr["labels"]:
195            if label["name"] == "release notes: yes":
196                rl_yes_found = True
197            elif label["name"] == "release notes: no":
198                rl_no_found = True
199            elif label["name"].startswith("lang/"):
200                lang_found = True
201                lang = label["name"].split("/")[1].lower()
202                # lang = lang[0].upper() + lang[1:]
203        body = pr["title"]
204        if not body.endswith("."):
205            body = body + "."
206
207        prline = (
208            "-  " + body + " ([#" + pr_num + "](" + HTML_URL + pr_num + "))"
209        )
210        detail = "- " + pr["user"]["login"] + "@ " + prline
211        print(detail)
212        # if no RL label
213        if not rl_no_found and not rl_yes_found:
214            print(("Release notes label missing for " + pr_num))
215            langs_pr["nolabel"].append(detail)
216        elif rl_yes_found and not lang_found:
217            print(("Lang label missing for " + pr_num))
218            langs_pr["nolang"].append(detail)
219        elif rl_no_found:
220            print(("'Release notes:no' found for " + pr_num))
221            langs_pr["notinrel"].append(detail)
222        elif rl_yes_found:
223            print(
224                (
225                    "'Release notes:yes' found for "
226                    + pr_num
227                    + " with lang "
228                    + lang
229                )
230            )
231            langs_pr["inrel"].append(detail)
232            langs_pr[lang].append(prline)
233    commits_wo_pr = all_commits_set - merge_commits_set - sq_commits_set
234    for commit in commits_wo_pr:
235        langs_pr["nopr"].append(get_commit_detail(commit))
236
237    return langs_pr, error_count
238
239
240def write_draft(langs_pr, file, version, date):
241    file.write(content_header.format(version=version, date=date))
242    file.write(
243        "Commits with missing PR number - please lookup the PR info in the corresponding CL and add to the additional notes if necessary.\n"
244    )
245    file.write("---\n")
246    file.write("\n")
247    if langs_pr["nopr"]:
248        file.write("\n".join(langs_pr["nopr"]))
249    else:
250        file.write("- None")
251    file.write("\n")
252    file.write("\n")
253    file.write("PRs with missing release notes label - please fix in Github\n")
254    file.write("---\n")
255    file.write("\n")
256    if langs_pr["nolabel"]:
257        langs_pr["nolabel"].sort()
258        file.write("\n".join(langs_pr["nolabel"]))
259    else:
260        file.write("- None")
261    file.write("\n")
262    file.write("\n")
263    file.write("PRs with missing lang label - please fix in Github\n")
264    file.write("---\n")
265    file.write("\n")
266    if langs_pr["nolang"]:
267        langs_pr["nolang"].sort()
268        file.write("\n".join(langs_pr["nolang"]))
269    else:
270        file.write("- None")
271    file.write("\n")
272    file.write("\n")
273    file.write(
274        "PRs going into release notes - please check title and fix in Github."
275        " Do not edit here.\n"
276    )
277    file.write("---\n")
278    file.write("\n")
279    if langs_pr["inrel"]:
280        langs_pr["inrel"].sort()
281        file.write("\n".join(langs_pr["inrel"]))
282    else:
283        file.write("- None")
284    file.write("\n")
285    file.write("\n")
286    file.write("PRs not going into release notes\n")
287    file.write("---\n")
288    file.write("\n")
289    if langs_pr["notinrel"]:
290        langs_pr["notinrel"].sort()
291        file.write("\n".join(langs_pr["notinrel"]))
292    else:
293        file.write("- None")
294    file.write("\n")
295    file.write("\n")
296
297
298def write_rel_notes(langs_pr, file, version, name):
299    file.write(rl_header.format(version=version, name=name))
300    if langs_pr["core"]:
301        file.write("Core\n---\n\n")
302        file.write("\n".join(langs_pr["core"]))
303        file.write("\n")
304        file.write("\n")
305    if langs_pr["c++"]:
306        file.write("C++\n---\n\n")
307        file.write("\n".join(langs_pr["c++"]))
308        file.write("\n")
309        file.write("\n")
310    if langs_pr["c#"]:
311        file.write("C#\n---\n\n")
312        file.write("\n".join(langs_pr["c#"]))
313        file.write("\n")
314        file.write("\n")
315    if langs_pr["go"]:
316        file.write("Go\n---\n\n")
317        file.write("\n".join(langs_pr["go"]))
318        file.write("\n")
319        file.write("\n")
320    if langs_pr["Java"]:
321        file.write("Java\n---\n\n")
322        file.write("\n".join(langs_pr["Java"]))
323        file.write("\n")
324        file.write("\n")
325    if langs_pr["node"]:
326        file.write("Node\n---\n\n")
327        file.write("\n".join(langs_pr["node"]))
328        file.write("\n")
329        file.write("\n")
330    if langs_pr["objc"]:
331        file.write("Objective-C\n---\n\n")
332        file.write("\n".join(langs_pr["objc"]))
333        file.write("\n")
334        file.write("\n")
335    if langs_pr["php"]:
336        file.write("PHP\n---\n\n")
337        file.write("\n".join(langs_pr["php"]))
338        file.write("\n")
339        file.write("\n")
340    if langs_pr["python"]:
341        file.write("Python\n---\n\n")
342        file.write("\n".join(langs_pr["python"]))
343        file.write("\n")
344        file.write("\n")
345    if langs_pr["ruby"]:
346        file.write("Ruby\n---\n\n")
347        file.write("\n".join(langs_pr["ruby"]))
348        file.write("\n")
349        file.write("\n")
350    if langs_pr["other"]:
351        file.write("Other\n---\n\n")
352        file.write("\n".join(langs_pr["other"]))
353        file.write("\n")
354        file.write("\n")
355
356
357def build_args_parser():
358    import argparse
359
360    parser = argparse.ArgumentParser()
361    parser.add_argument(
362        "release_version", type=str, help="New release version e.g. 1.14.0"
363    )
364    parser.add_argument(
365        "release_name", type=str, help="New release name e.g. gladiolus"
366    )
367    parser.add_argument(
368        "release_date", type=str, help="Release date e.g. 7/30/18"
369    )
370    parser.add_argument(
371        "previous_release_label",
372        type=str,
373        help="Previous release branch/tag e.g. v1.13.x",
374    )
375    parser.add_argument(
376        "release_branch",
377        type=str,
378        help="Current release branch e.g. origin/v1.14.x",
379    )
380    parser.add_argument(
381        "draft_filename", type=str, help="Name of the draft file e.g. draft.md"
382    )
383    parser.add_argument(
384        "release_notes_filename",
385        type=str,
386        help="Name of the release notes file e.g. relnotes.md",
387    )
388    parser.add_argument(
389        "--token",
390        type=str,
391        default="",
392        help="GitHub API token to avoid being rate limited",
393    )
394    return parser
395
396
397def main():
398    import os
399
400    global TOKEN
401
402    parser = build_args_parser()
403    args = parser.parse_args()
404    version, name, date = (
405        args.release_version,
406        args.release_name,
407        args.release_date,
408    )
409    start, end = args.previous_release_label, args.release_branch
410
411    TOKEN = args.token
412    if TOKEN == "":
413        try:
414            TOKEN = os.environ["GITHUB_TOKEN"]
415        except:
416            pass
417    if TOKEN == "":
418        print(
419            "Error: Github API token required. Either include param"
420            " --token=<your github token> or set environment variable"
421            " GITHUB_TOKEN to your github token"
422        )
423        return
424
425    langs_pr, error_count = get_pr_titles(get_commit_log(start, end))
426
427    draft_file, rel_file = args.draft_filename, args.release_notes_filename
428    filename = os.path.abspath(draft_file)
429    if os.path.exists(filename):
430        file = open(filename, "r+")
431    else:
432        file = open(filename, "w")
433
434    file.seek(0)
435    write_draft(langs_pr, file, version, date)
436    file.truncate()
437    file.close()
438    print(("\nDraft notes written to " + filename))
439
440    filename = os.path.abspath(rel_file)
441    if os.path.exists(filename):
442        file = open(filename, "r+")
443    else:
444        file = open(filename, "w")
445
446    file.seek(0)
447    write_rel_notes(langs_pr, file, version, name)
448    file.truncate()
449    file.close()
450    print(("\nRelease notes written to " + filename))
451    if error_count > 0:
452        print("\n\n*** Errors were encountered. See log. *********\n")
453
454
455if __name__ == "__main__":
456    main()
457