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