make_release_notes.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. #!/usr/bin/env python
  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. 'Functions/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. for issue in issue_list:
  118. issue = issue.translate(None, string.punctuation)
  119. link = '//github.com/%s/issues/%s' % (self.local_repo, issue)
  120. issue_link_list.append('[#%s](%s)' % (issue, link))
  121. return "(" + ", ".join(issue_link_list) + ")"
  122. def text(self, text):
  123. """Passes through any other text."""
  124. return text
  125. class Translator(object):
  126. def __init__(self, renderer):
  127. self.renderer = renderer
  128. def translate(self, text):
  129. result = ''
  130. while text:
  131. for key in self.rules:
  132. rule = getattr(self, key)
  133. m = rule.match(text)
  134. if not m:
  135. continue
  136. callback = getattr(self, 'parse_' + key)
  137. callback_result = callback(m)
  138. result += callback_result
  139. text = text[len(m.group(0)):]
  140. break
  141. return result
  142. heading = re.compile(
  143. r'^#{1,6} .*'
  144. )
  145. def parse_heading(self, m):
  146. return self.renderer.heading(m.group(0))
  147. bullet = re.compile(
  148. r'^(\s*)[*+-] '
  149. )
  150. def parse_bullet(self, m):
  151. return self.renderer.bullet(m.group(1))
  152. change_type = re.compile(
  153. r'\[' # opening square bracket
  154. r'(\w+)' # tag word (like "feature" or "changed")
  155. r'\]' # closing square bracket
  156. r'(?!\()' # not followed by opening paren (that would be a link)
  157. )
  158. def parse_change_type(self, m):
  159. return self.renderer.change_type(m.group(1))
  160. url = re.compile(r'^(https?://[^\s<]+[^<.,:;"\')\]\s])')
  161. def parse_url(self, m):
  162. return self.renderer.url(m.group(1))
  163. local_issue_link = re.compile(
  164. r'\(' # opening paren
  165. r'(#(\d+)(, )?)+' # list of hash and issue number, comma-delimited
  166. r'\)' # closing paren
  167. )
  168. def parse_local_issue_link(self, m):
  169. return self.renderer.local_issue_link(m.group(0))
  170. text = re.compile(
  171. r'^[\s\S]+?(?=[(\[\n]|https?://|$)'
  172. )
  173. def parse_text(self, m):
  174. return self.renderer.text(m.group(0))
  175. rules = [
  176. 'heading', 'bullet', 'change_type', 'url', 'local_issue_link', 'text'
  177. ]
  178. def read_file(filename):
  179. """Reads the contents of the file as a single string."""
  180. with open(filename, 'r') as fd:
  181. return fd.read()
  182. def read_changelog_section(filename, single_version=None):
  183. """Reads a single section of the changelog from the given filename.
  184. If single_version is None, reads the first section with a number in its
  185. heading. Otherwise, reads the first section with single_version in its
  186. heading.
  187. Args:
  188. - single_version: specifies a string to look for in headings.
  189. Returns:
  190. A string containing the heading and contents of the heading.
  191. """
  192. with open(filename, 'r') as fd:
  193. # Discard all lines until we see a heading that either has the version the
  194. # user asked for or any version.
  195. if single_version:
  196. initial_heading = re.compile(r'^#{1,6} .*%s' % re.escape(single_version))
  197. else:
  198. initial_heading = re.compile(r'^#{1,6} ([^\d]*)\d')
  199. heading = re.compile(r'^#{1,6} ')
  200. initial = True
  201. result = []
  202. for line in fd:
  203. if initial:
  204. if initial_heading.match(line):
  205. initial = False
  206. result.append(line)
  207. else:
  208. if heading.match(line):
  209. break
  210. result.append(line)
  211. # Prune extra newlines
  212. while result and result[-1] == '\n':
  213. result.pop()
  214. return ''.join(result)
  215. if __name__ == '__main__':
  216. main()