diff --git a/docs/make_release_notes.py b/docs/make_release_notes.py index 15256d52c58..ac15b14c254 100644 --- a/docs/make_release_notes.py +++ b/docs/make_release_notes.py @@ -13,234 +13,383 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - """Converts GitHub flavored markdown changelogs to release notes. """ import argparse +import configparser import re -import subprocess +import os import string - -import six - -NO_HEADING = 'PRODUCT HAS NO HEADING' - - +from dataclasses import dataclass, field + + +@dataclass +class Changelog: + path: str + target_path: str + alt_name: str + version_name: str + has_ktx: bool = True + ktx_placeholder: str = None + version: str = field(init=False) + + def __post_init__(self): + self.version = self._get_version() + + def get_header(self): + version_str = f'{{: #{self.version_name}_v{self.version.replace(".", "-")}}}' + return f'### {self.alt_name} version {self.version} {version_str}\n' + + def get_ktx_header(self): + if not self.has_ktx: + return "" + version_str = f'{{: #{self.version_name}-ktx_v{self.version.replace(".", "-")}}}' + return f'### {self.alt_name} Kotlin extensions version {self.version} {version_str}\n' + + def _get_version(self): + properties = os.path.join(os.path.dirname(self.path), + 'gradle.properties') + if not os.path.exists(properties): + return PLACEHOLDER_VERSION + + with open(properties, 'r') as fd: + for line in fd: + if line.startswith('version='): + return line.removeprefix('version=').strip() + return PLACEHOLDER_VERSION + + +REPO = 'firebase/firebase-android-sdk' +CHANGE_TYPE_MAPPING = {'added': 'feature'} +PLACEHOLDER_VERSION = 'xx.x.x' PRODUCTS = { - 'FirebaseABTesting/CHANGELOG.md': '{{ab_testing}}', - 'FirebaseAppCheck/CHANGELOG.md': 'App Check', - 'FirebaseAppDistribution/CHANGELOG.md': 'App Distribution', - 'FirebaseAuth/CHANGELOG.md': '{{auth}}', - 'FirebaseCore/CHANGELOG.md': NO_HEADING, - 'Crashlytics/CHANGELOG.md': '{{crashlytics}}', - 'FirebaseDatabase/CHANGELOG.md': '{{database}}', - 'FirebaseDynamicLinks/CHANGELOG.md': '{{ddls}}', - 'FirebaseInAppMessaging/CHANGELOG.md': '{{inapp_messaging}}', - 'FirebaseInstallations/CHANGELOG.md': 'Installations', - 'FirebaseMessaging/CHANGELOG.md': '{{messaging}}', - 'FirebaseStorage/CHANGELOG.md': '{{storage}}', - 'Firestore/CHANGELOG.md': '{{firestore}}', - 'FirebaseFunctions/CHANGELOG.md': '{{cloud_functions}}', - 'FirebaseRemoteConfig/CHANGELOG.md': '{{remote_config}}', - 'FirebasePerformance/CHANGELOG.md': '{{perfmon}}', + 'firebase-abt': + Changelog(path='firebase-abt/CHANGELOG.md', + target_path='firebase-abt', + has_ktx=False, + alt_name='{{ab_testing}}', + version_name='ab-testing'), + 'firebase-appdistribution': + Changelog(path='firebase-appdistribution/CHANGELOG.md', + target_path='firebase-appdistribution', + has_ktx=False, + alt_name='{{appdistro}}', + version_name='app-distro'), + 'firebase-appdistribution-api': + Changelog(path='firebase-appdistribution-api/CHANGELOG.md', + target_path='firebase-appdistribution-api', + alt_name='{{appdistro}} API', + ktx_placeholder='firebase-appdistribution-api', + version_name='app-distro-api'), + 'firebase-config': + Changelog(path='firebase-config/CHANGELOG.md', + target_path='firebase-config', + alt_name='{{remote_config}}', + ktx_placeholder='firebase-config', + version_name='remote-config'), + 'firebase-crashlytics': + Changelog(path='firebase-crashlytics/CHANGELOG.md', + target_path='firebase-crashlytics', + alt_name='{{crashlytics}}', + ktx_placeholder='firebase-crashlytics', + version_name='crashlytics'), + 'firebase-crashlytics-ndk': + Changelog(path='firebase-crashlytics-ndk/CHANGELOG.md', + target_path='firebase-crashlytics-ndk', + has_ktx=False, + alt_name='{{crashlytics}} NDK', + version_name='crashlytics-ndk'), + 'firebase-database': + Changelog(path='firebase-database/CHANGELOG.md', + target_path='firebase-database', + alt_name='{{database}}', + ktx_placeholder='firebase-database', + version_name='realtime-database'), + 'firebase-dynamic-links': + Changelog(path='firebase-dynamic-links/CHANGELOG.md', + target_path='firebase-dynamic-links', + alt_name='{{ddls}}', + ktx_placeholder='firebase-dynamic-links', + version_name='dynamic-links'), + 'firebase-firestore': + Changelog(path='firebase-firestore/CHANGELOG.md', + target_path='firebase-firestore', + alt_name='{{firestore}}', + ktx_placeholder='firebase-firestore', + version_name='firestore'), + 'firebase-functions': + Changelog(path='firebase-functions/CHANGELOG.md', + target_path='firebase-functions', + alt_name='{{functions_client}}', + ktx_placeholder='firebase-functions', + version_name='functions-client'), + 'firebase-dynamic-module-support': + Changelog( + path= + 'firebase-components/firebase-dynamic-module-support/CHANGELOG.md', + target_path='firebase-dynamic-module-support', + has_ktx=False, + alt_name='Dynamic feature modules support', + version_name='dynamic-feature-modules-support'), + 'firebase-inappmessaging': + Changelog(path='firebase-inappmessaging/CHANGELOG.md', + target_path='firebase-inappmessaging', + alt_name='{{inappmessaging}}', + ktx_placeholder='firebase-inappmessaging', + version_name='inappmessaging'), + 'firebase-inappmessaging-display': + Changelog(path='firebase-inappmessaging-display/CHANGELOG.md', + target_path='firebase-inappmessaging-display', + alt_name='{{inappmessaging}} Display', + ktx_placeholder='firebase-inappmessaging-display', + version_name='inappmessaging-display'), + 'firebase-installations': + Changelog(path='firebase-installations/CHANGELOG.md', + target_path='firebase-installations', + alt_name='{{firebase_installations}}', + ktx_placeholder='firebase-installations', + version_name='installations'), + 'firebase-messaging': + Changelog(path='firebase-messaging/CHANGELOG.md', + target_path='firebase-messaging', + alt_name='{{messaging_longer}}', + ktx_placeholder='firebase-messaging', + version_name='messaging'), + 'firebase-messaging-directboot': + Changelog(path='firebase-messaging-directboot/CHANGELOG.md', + target_path='firebase-messaging-directboot', + has_ktx=False, + alt_name='Cloud Messaging Direct Boot', + version_name='messaging-directboot'), + 'firebase-ml-modeldownloader': + Changelog(path='firebase-ml-modeldownloader/CHANGELOG.md', + target_path='firebase-ml-modeldownloader', + alt_name='{{firebase_ml}}', + ktx_placeholder='firebase-ml-modeldownloader', + version_name='firebaseml-modeldownloader'), + 'firebase-perf': + Changelog(path='firebase-perf/CHANGELOG.md', + target_path='firebase-perf', + alt_name='{{perfmon}}', + ktx_placeholder='firebase-performance', + version_name='performance'), + 'firebase-storage': + Changelog(path='firebase-storage/CHANGELOG.md', + target_path='firebase-storage-api', + alt_name='{{firebase_storage_full}}', + ktx_placeholder='firebase-storage', + version_name='storage'), + 'appcheck:firebase-appcheck': + Changelog(path='appcheck/firebase-appcheck/CHANGELOG.md', + target_path='firebase-appcheck', + alt_name='{{app_check}}', + ktx_placeholder='firebase-appcheck', + version_name='appcheck'), + 'appcheck:firebase-appcheck-debug': + Changelog(path='appcheck/firebase-appcheck-debug/CHANGELOG.md', + target_path='firebase-appcheck-debug', + has_ktx=False, + alt_name='{{app_check}} Debug', + version_name='appcheck-debug'), + 'appcheck:firebase-appcheck-debug-testing': + Changelog(path='appcheck/firebase-appcheck-debug-testing/CHANGELOG.md', + target_path='firebase-appcheck-debug-testing', + has_ktx=False, + alt_name='{{app_check}} Debug Testing', + version_name='appcheck-debug-testing'), + 'appcheck:firebase-appcheck-playintegrity': + Changelog(path='appcheck/firebase-appcheck-playintegrity/CHANGELOG.md', + target_path='firebase-appcheck-playintegrity', + has_ktx=False, + alt_name='{{app_check}} Play integrity', + version_name='appcheck-playintegrity'), + 'appcheck:firebase-appcheck-safetynet': + Changelog(path='appcheck/firebase-appcheck-safetynet/CHANGELOG.md', + target_path='firebase-appcheck-safetynet', + has_ktx=False, + alt_name='{{app_check}} SafetyNet', + version_name='appcheck-safetynet') } +KTX_PLACEHOLDER_TEXT = """ +The Kotlin extensions library transitively includes the updated +`PLACEHOLDER_NAME` library. The Kotlin extensions library has no additional +updates. +""" - -def main(): - local_repo = find_local_repo() - - parser = argparse.ArgumentParser(description='Create release notes.') - parser.add_argument('--repo', '-r', default=local_repo, - help='Specify which GitHub repo is local.') - parser.add_argument('--only', metavar='VERSION', - help='Convert only a specific version') - parser.add_argument('--all', action='store_true', - help='Emits entries for all versions') - parser.add_argument('changelog', - help='The CHANGELOG.md file to parse') - args = parser.parse_args() - - if args.all: - text = read_file(args.changelog) - else: - text = read_changelog_section(args.changelog, args.only) - - product = None - if not args.all: - product = PRODUCTS.get(args.changelog) - - renderer = Renderer(args.repo, product) - translator = Translator(renderer) - - result = translator.translate(text) - print(result) - - -def find_local_repo(): - url = six.ensure_text( - subprocess.check_output(['git', 'config', '--get', 'remote.origin.url'])) - - # ssh or https style URL - m = re.match(r'^(?:git@github\.com:|https://github\.com/)(.*)\.git$', url) - if m: - return m.group(1) - - raise LookupError('Can\'t figure local repo from remote URL %s' % url) +def releasing_products(release_cfg_path): + config = configparser.ConfigParser(allow_no_value=True, delimiters=('=',)) + config.read(release_cfg_path) + return list(config['modules']) -CHANGE_TYPE_MAPPING = { - 'added': 'feature' -} +def main(): + parser = argparse.ArgumentParser(description='Create release notes.') + parser.add_argument('--releasecfg', + default='release.cfg', + required=False, + help='Path to the release.cfg file to use') + parser.add_argument('--products', + required=False, + help='Comma separated list of products to process') + parser.add_argument('--generated_name', + default='changelog', + required=False, + help='Name for generated files, without extension.') + args = parser.parse_args() + + if args.products: + products = args.products.split(',') + else: + products = releasing_products(args.releasecfg) + + for product in products: + if product.startswith(':'): + product = product.removeprefix(':') + if not product in PRODUCTS: + print(f'Ignored: {product}') + continue + + changelog = PRODUCTS[product] + renderer = Renderer(changelog) + translator = Translator(renderer) + path = f'build/changelog/android/client/{changelog.target_path}/_releases' + os.makedirs(path, exist_ok=True) + with open(f'{path}/{args.generated_name}.md', 'w') as fd: + fd.write( + translator.translate( + read_changelog_section(changelog, 'Unreleased'))) class Renderer(object): - def __init__(self, local_repo, product): - self.local_repo = local_repo - self.product = product - - def heading(self, heading): - if self.product: - if self.product == NO_HEADING: - return '' - else: - return '### %s\n' % self.product + def __init__(self, changelog): + self.changelog = changelog - return heading + def heading(self, heading): + return heading - def bullet(self, spacing): - """Renders a bullet in a list. + def bullet(self, spacing): + """Renders a bullet in a list. All bulleted lists in devsite are '*' style. """ - return '%s* ' % spacing + return f'{spacing}* ' - def change_type(self, tag): - """Renders a change type tag as the appropriate double-braced macro. + def change_type(self, tag): + """Renders a change type tag as the appropriate double-braced macro. That is "[fixed]" is rendered as "{{fixed}}". """ - tag = CHANGE_TYPE_MAPPING.get(tag, tag) - return '{{%s}}' % tag + tag = CHANGE_TYPE_MAPPING.get(tag, tag) + return '{{%s}}' % tag - def url(self, url): - m = re.match(r'^(?:https:)?(//github.com/(.*)/issues/(\d+))$', url) - if m: - link = m.group(1) - repo = m.group(2) - issue = m.group(3) + def url(self, url): + m = re.match(r'^(?:https:)?(//github.com/(.*)/issues/(\d+))$', url) + if m: + link = m.group(1) + repo = m.group(2) + issue = m.group(3) - if repo == self.local_repo: - text = '#' + issue - else: - text = repo + '#' + issue + if repo == REPO: + text = f'#{issue}' + else: + text = f'{repo}#{issue}' - return '[%s](%s)' % (text, link) + return f'[{text}]({link})' - return url + return url - def local_issue_link(self, issues): - """Renders a local issue link as a proper markdown URL. + def local_issue_link(self, issues): + """Renders a local issue link as a proper markdown URL. Transforms (#1234, #1235) into - ([#1234](//github.com/firebase/firebase-ios-sdk/issues/1234), - [#1235](//github.com/firebase/firebase-ios-sdk/issues/1235)). + ([#1234](//github.com/firebase/firebase-android-sdk/issues/1234), + [#1235](//github.com/firebase/firebase-android-sdk/issues/1235)). """ - issue_link_list = [] - issue_list = issues.split(", ") - translate = str.maketrans('', '', string.punctuation) - for issue in issue_list: - issue = issue.translate(translate) - link = '//github.com/%s/issues/%s' % (self.local_repo, issue) - issue_link_list.append('[#%s](%s)' % (issue, link)) - return "(" + ", ".join(issue_link_list) + ")" + issue_link_list = [] + issue_list = issues.split(', ') + translate = str.maketrans('', '', string.punctuation) + for issue in issue_list: + issue = issue.translate(translate) + link = f'//github.com/{REPO}/issues/{issue}' + issue_link_list.append(f'[#{issue}]({link})') + return '(' + ', '.join(issue_link_list) + ')' - def text(self, text): - """Passes through any other text.""" - return text + def text(self, text): + """Passes through any other text.""" + return text class Translator(object): - def __init__(self, renderer): - self.renderer = renderer - def translate(self, text): - result = '' - while text: - for key in self.rules: - rule = getattr(self, key) - m = rule.match(text) - if not m: - continue + def __init__(self, renderer): + self.renderer = renderer - callback = getattr(self, 'parse_' + key) - callback_result = callback(m) - result += callback_result + def translate(self, text): + result = '' + while text: + for key in self.rules: + rule = getattr(self, key) + m = rule.match(text) + if not m: + continue - text = text[len(m.group(0)):] - break + callback = getattr(self, 'parse_' + key) + callback_result = callback(m) + result += callback_result - return result + text = text[len(m.group(0)):] + break - heading = re.compile( - r'^#{1,6} .*' - ) + return result - def parse_heading(self, m): - return self.renderer.heading(m.group(0)) + heading = re.compile(r'^#{1,6} .*') - bullet = re.compile( - r'^(\s*)[*+-] ' - ) + def parse_heading(self, m): + return self.renderer.heading(m.group(0)) - def parse_bullet(self, m): - return self.renderer.bullet(m.group(1)) + bullet = re.compile(r'^(\s*)[*+-] ') - change_type = re.compile( - r'\[' # opening square bracket - r'(\w+)' # tag word (like "feature" or "changed") - r'\]' # closing square bracket - r'(?!\()' # not followed by opening paren (that would be a link) - ) + def parse_bullet(self, m): + return self.renderer.bullet(m.group(1)) - def parse_change_type(self, m): - return self.renderer.change_type(m.group(1)) + change_type = re.compile( + r'\[' # opening square bracket + r'(\w+)' # tag word (like "feature" or "changed") + r'\]' # closing square bracket + r'(?!\()' # not followed by opening paren (that would be a link) + ) - url = re.compile(r'^(https?://[^\s<]+[^<.,:;"\')\]\s])') + def parse_change_type(self, m): + return self.renderer.change_type(m.group(1)) - def parse_url(self, m): - return self.renderer.url(m.group(1)) + url = re.compile(r'^(https?://[^\s<]+[^<.,:;"\')\]\s])') - local_issue_link = re.compile( - r'\(' # opening paren - r'(#(\d+)(, )?)+' # list of hash and issue number, comma-delimited - r'\)' # closing paren - ) + def parse_url(self, m): + return self.renderer.url(m.group(1)) - def parse_local_issue_link(self, m): - return self.renderer.local_issue_link(m.group(0)) + local_issue_link = re.compile( + r'\(' # opening paren + r'(#(\d+)(, )?)+' # list of hash and issue number, comma-delimited + r'\)' # closing paren + ) - text = re.compile( - r'^[\s\S]+?(?=[(\[\n]|https?://|$)' - ) + def parse_local_issue_link(self, m): + return self.renderer.local_issue_link(m.group(0)) - def parse_text(self, m): - return self.renderer.text(m.group(0)) + text = re.compile(r'^[\s\S]+?(?=[(\[\n]|https?://|$)') - rules = [ - 'heading', 'bullet', 'change_type', 'url', 'local_issue_link', 'text' - ] + def parse_text(self, m): + return self.renderer.text(m.group(0)) + rules = [ + 'heading', 'bullet', 'change_type', 'url', 'local_issue_link', 'text' + ] -def read_file(filename): - """Reads the contents of the file as a single string.""" - with open(filename, 'r') as fd: - return fd.read() - -def read_changelog_section(filename, single_version=None): - """Reads a single section of the changelog from the given filename. +def read_changelog_section(changelog, single_version=None): + """Reads a single section of the changelog from the given filename. If single_version is None, reads the first section with a number in its heading. Otherwise, reads the first section with single_version in its @@ -252,36 +401,43 @@ def read_changelog_section(filename, single_version=None): Returns: A string containing the heading and contents of the heading. """ - with open(filename, 'r') as fd: - # Discard all lines until we see a heading that either has the version the - # user asked for or any version. - if single_version: - initial_heading = re.compile(r'^#{1,6} .*%s' % re.escape(single_version)) - else: - initial_heading = re.compile(r'^#{1,6} ([^\d]*)\d') + with open(changelog.path, 'r') as fd: + # Discard all lines until we see a heading that either has the version the + # user asked for or any version. + if single_version: + initial_heading = re.compile(r'^#{1,6} .*%s' % + re.escape(single_version)) + else: + initial_heading = re.compile(r'^#{1,6} ([^\d]*)\d') + + heading = re.compile(r'^#{1,6} ') - heading = re.compile(r'^#{1,6} ') + initial = True + result = [] + for line in fd: + if initial: + if initial_heading.match(line): + initial = False + result.append(f'{changelog.get_header()}\n') - initial = True - result = [] - for line in fd: - if initial: - if initial_heading.match(line): - initial = False - result.append(line) + else: + if heading.match(line): + break - else: - if heading.match(line): - break + result.append(line) - result.append(line) + # Prune extra newlines + while result and result[-1] == '\n': + result.pop() - # Prune extra newlines - while result and result[-1] == '\n': - result.pop() + # Append ktx section + if changelog.has_ktx: + result.append('\n') + result.append(changelog.get_ktx_header()) + result.append(KTX_PLACEHOLDER_TEXT.replace('PLACEHOLDER_NAME', changelog.ktx_placeholder)) - return ''.join(result) + return ''.join(result) if __name__ == '__main__': - main() + main()