make_release_notes.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. #!/usr/bin/env python3
  2. # Copyright 2019 Google LLC
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. """Converts GitHub flavored markdown changelogs to release notes.
  16. """
  17. import argparse
  18. import re
  19. import subprocess
  20. import string
  21. import six
  22. NO_HEADING = 'PRODUCT HAS NO HEADING'
  23. PRODUCTS = {
  24. 'FirebaseABTesting/CHANGELOG.md': '{{ab_testing}}',
  25. 'FirebaseAppCheck/CHANGELOG.md': 'App Check',
  26. 'FirebaseAppDistribution/CHANGELOG.md': 'App Distribution',
  27. 'FirebaseAuth/CHANGELOG.md': '{{auth}}',
  28. 'FirebaseCore/CHANGELOG.md': NO_HEADING,
  29. 'Crashlytics/CHANGELOG.md': '{{crashlytics}}',
  30. 'FirebaseDatabase/CHANGELOG.md': '{{database}}',
  31. 'FirebaseDynamicLinks/CHANGELOG.md': '{{ddls}}',
  32. 'FirebaseInAppMessaging/CHANGELOG.md': '{{inapp_messaging}}',
  33. 'FirebaseInstallations/CHANGELOG.md': 'Installations',
  34. 'FirebaseMessaging/CHANGELOG.md': '{{messaging}}',
  35. 'FirebaseStorage/CHANGELOG.md': '{{storage}}',
  36. 'Firestore/CHANGELOG.md': '{{firestore}}',
  37. 'FirebaseFunctions/CHANGELOG.md': '{{cloud_functions}}',
  38. 'FirebaseRemoteConfig/CHANGELOG.md': '{{remote_config}}',
  39. 'FirebasePerformance/CHANGELOG.md': '{{perfmon}}',
  40. }
  41. def main():
  42. local_repo = find_local_repo()
  43. parser = argparse.ArgumentParser(description='Create release notes.')
  44. parser.add_argument('--repo', '-r', default=local_repo,
  45. help='Specify which GitHub repo is local.')
  46. parser.add_argument('--only', metavar='VERSION',
  47. help='Convert only a specific version')
  48. parser.add_argument('--all', action='store_true',
  49. help='Emits entries for all versions')
  50. parser.add_argument('changelog',
  51. help='The CHANGELOG.md file to parse')
  52. args = parser.parse_args()
  53. if args.all:
  54. text = read_file(args.changelog)
  55. else:
  56. text = read_changelog_section(args.changelog, args.only)
  57. product = None
  58. if not args.all:
  59. product = PRODUCTS.get(args.changelog)
  60. renderer = Renderer(args.repo, product)
  61. translator = Translator(renderer)
  62. result = translator.translate(text)
  63. print(result)
  64. def find_local_repo():
  65. url = six.ensure_text(
  66. subprocess.check_output(['git', 'config', '--get', 'remote.origin.url']))
  67. # ssh or https style URL
  68. m = re.match(r'^(?:git@github\.com:|https://github\.com/)(.*)\.git$', url)
  69. if m:
  70. return m.group(1)
  71. raise LookupError('Can\'t figure local repo from remote URL %s' % url)
  72. CHANGE_TYPE_MAPPING = {
  73. 'added': 'feature'
  74. }
  75. class Renderer(object):
  76. def __init__(self, local_repo, product):
  77. self.local_repo = local_repo
  78. self.product = product
  79. def heading(self, heading):
  80. if self.product:
  81. if self.product == NO_HEADING:
  82. return ''
  83. else:
  84. return '### %s\n' % self.product
  85. return heading
  86. def bullet(self, spacing):
  87. """Renders a bullet in a list.
  88. All bulleted lists in devsite are '*' style.
  89. """
  90. return '%s* ' % spacing
  91. def change_type(self, tag):
  92. """Renders a change type tag as the appropriate double-braced macro.
  93. That is "[fixed]" is rendered as "{{fixed}}".
  94. """
  95. tag = CHANGE_TYPE_MAPPING.get(tag, tag)
  96. return '{{%s}}' % tag
  97. def url(self, url):
  98. m = re.match(r'^(?:https:)?(//github.com/(.*)/issues/(\d+))$', url)
  99. if m:
  100. link = m.group(1)
  101. repo = m.group(2)
  102. issue = m.group(3)
  103. if repo == self.local_repo:
  104. text = '#' + issue
  105. else:
  106. text = repo + '#' + issue
  107. return '[%s](%s)' % (text, link)
  108. return url
  109. def local_issue_link(self, issues):
  110. """Renders a local issue link as a proper markdown URL.
  111. Transforms (#1234, #1235) into
  112. ([#1234](//github.com/firebase/firebase-ios-sdk/issues/1234),
  113. [#1235](//github.com/firebase/firebase-ios-sdk/issues/1235)).
  114. """
  115. issue_link_list = []
  116. issue_list = issues.split(", ")
  117. translate = str.maketrans('', '', string.punctuation)
  118. for issue in issue_list:
  119. issue = issue.translate(translate)
  120. link = '//github.com/%s/issues/%s' % (self.local_repo, issue)
  121. issue_link_list.append('[#%s](%s)' % (issue, link))
  122. return "(" + ", ".join(issue_link_list) + ")"
  123. def text(self, text):
  124. """Passes through any other text."""
  125. return text
  126. class Translator(object):
  127. def __init__(self, renderer):
  128. self.renderer = renderer
  129. def translate(self, text):
  130. result = ''
  131. while text:
  132. for key in self.rules:
  133. rule = getattr(self, key)
  134. m = rule.match(text)
  135. if not m:
  136. continue
  137. callback = getattr(self, 'parse_' + key)
  138. callback_result = callback(m)
  139. result += callback_result
  140. text = text[len(m.group(0)):]
  141. break
  142. return result
  143. heading = re.compile(
  144. r'^#{1,6} .*'
  145. )
  146. def parse_heading(self, m):
  147. return self.renderer.heading(m.group(0))
  148. bullet = re.compile(
  149. r'^(\s*)[*+-] '
  150. )
  151. def parse_bullet(self, m):
  152. return self.renderer.bullet(m.group(1))
  153. change_type = re.compile(
  154. r'\[' # opening square bracket
  155. r'(\w+)' # tag word (like "feature" or "changed")
  156. r'\]' # closing square bracket
  157. r'(?!\()' # not followed by opening paren (that would be a link)
  158. )
  159. def parse_change_type(self, m):
  160. return self.renderer.change_type(m.group(1))
  161. url = re.compile(r'^(https?://[^\s<]+[^<.,:;"\')\]\s])')
  162. def parse_url(self, m):
  163. return self.renderer.url(m.group(1))
  164. local_issue_link = re.compile(
  165. r'\(' # opening paren
  166. r'(#(\d+)(, )?)+' # list of hash and issue number, comma-delimited
  167. r'\)' # closing paren
  168. )
  169. def parse_local_issue_link(self, m):
  170. return self.renderer.local_issue_link(m.group(0))
  171. text = re.compile(
  172. r'^[\s\S]+?(?=[(\[\n]|https?://|$)'
  173. )
  174. def parse_text(self, m):
  175. return self.renderer.text(m.group(0))
  176. rules = [
  177. 'heading', 'bullet', 'change_type', 'url', 'local_issue_link', 'text'
  178. ]
  179. def read_file(filename):
  180. """Reads the contents of the file as a single string."""
  181. with open(filename, 'r') as fd:
  182. return fd.read()
  183. def read_changelog_section(filename, single_version=None):
  184. """Reads a single section of the changelog from the given filename.
  185. If single_version is None, reads the first section with a number in its
  186. heading. Otherwise, reads the first section with single_version in its
  187. heading.
  188. Args:
  189. - single_version: specifies a string to look for in headings.
  190. Returns:
  191. A string containing the heading and contents of the heading.
  192. """
  193. with open(filename, 'r') as fd:
  194. # Discard all lines until we see a heading that either has the version the
  195. # user asked for or any version.
  196. if single_version:
  197. initial_heading = re.compile(r'^#{1,6} .*%s' % re.escape(single_version))
  198. else:
  199. initial_heading = re.compile(r'^#{1,6} ([^\d]*)\d')
  200. heading = re.compile(r'^#{1,6} ')
  201. initial = True
  202. result = []
  203. for line in fd:
  204. if initial:
  205. if initial_heading.match(line):
  206. initial = False
  207. result.append(line)
  208. else:
  209. if heading.match(line):
  210. break
  211. result.append(line)
  212. # Prune extra newlines
  213. while result and result[-1] == '\n':
  214. result.pop()
  215. return ''.join(result)
  216. if __name__ == '__main__':
  217. main()