release_notes.py 12 KB

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