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 the {version} release ([{name}](https://github.com/grpc/grpc/blob/master/doc/g_stands_for.md)) of gRPC Core. 73 74Please see the notes for the previous releases here: https://github.com/grpc/grpc/releases. Please consult https://grpc.io/ for all information regarding this product. 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