# Copyright 2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an 'AS IS' BASIS, # 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. import json import argparse import logging import os import api_info import datetime import pytz STATUS_ADD = 'ADDED' STATUS_REMOVED = 'REMOVED' STATUS_MODIFIED = 'MODIFIED' STATUS_ERROR = 'BUILD ERROR' def main(): logging.getLogger().setLevel(logging.INFO) args = parse_cmdline_args() pr_branch = os.path.expanduser(args.pr_branch) base_branch = os.path.expanduser(args.base_branch) new_api_json = json.load( open(os.path.join(pr_branch, api_info.API_INFO_FILE_NAME))) old_api_json = json.load( open(os.path.join(base_branch, api_info.API_INFO_FILE_NAME))) diff = generate_diff_json(new_api_json, old_api_json) if diff: logging.info(f'json diff: \n{json.dumps(diff, indent=2)}') logging.info(f'plain text diff report: \n{generate_text_report(diff)}') logging.info(f'markdown diff report: \n{generate_markdown_report(diff)}') else: logging.info('No API Diff Detected.') def generate_diff_json(new_api, old_api, level='module'): """diff_json only contains module & api that has a change. format: { $(module_name_1): { "api_types": { $(api_type_1): { "apis": { $(api_1): { "declaration": [ $(api_1_declaration) ], "sub_apis": { $(sub_api_1): { "declaration": [ $(sub_api_1_declaration) ] }, }, "status": $(diff_status) } } } } } } """ NEXT_LEVEL = {'module': 'api_types', 'api_types': 'apis', 'apis': 'sub_apis'} next_level = NEXT_LEVEL.get(level) diff = {} for key in set(new_api.keys()).union(old_api.keys()): # Added API if key not in old_api: diff[key] = new_api[key] diff[key]['status'] = STATUS_ADD if diff[key].get('declaration'): diff[key]['declaration'] = [STATUS_ADD] + diff[key]['declaration'] # Removed API elif key not in new_api: diff[key] = old_api[key] diff[key]['status'] = STATUS_REMOVED if diff[key].get('declaration'): diff[key]['declaration'] = [STATUS_REMOVED] + diff[key]['declaration'] # Module Build Error. If a "module" exist but have no # content (e.g. doc_path), it must have a build error. elif level == 'module' and (not new_api[key]['path'] or not old_api[key]['path']): diff[key] = {'status': STATUS_ERROR} # Check diff in child level and diff in declaration else: child_diff = generate_diff_json(new_api[key][next_level], old_api[key][next_level], level=next_level) if next_level else {} declaration_diff = new_api[key].get('declaration') != old_api[key].get( 'declaration') if level in ['apis', 'sub_apis'] else False # No diff if not child_diff and not declaration_diff: continue diff[key] = new_api[key] # Changes at child level if child_diff: diff[key][next_level] = child_diff # Modified API (changes in API declaration) if declaration_diff: diff[key]['status'] = STATUS_MODIFIED diff[key]['declaration'] = [STATUS_ADD] + \ new_api[key]['declaration'] + \ [STATUS_REMOVED] + \ old_api[key]['declaration'] return diff def generate_text_report(diff, level=0, print_key=True): report = '' indent_str = ' ' * level for key, value in diff.items(): # filter out ["path", "api_type_link", "api_link", "declaration", "status"] if isinstance(value, dict): if key in ['api_types', 'apis', 'sub_apis']: report += generate_text_report(value, level=level) else: status_text = f"{value.get('status', '')}:" if 'status' in value else '' if status_text: if print_key: report += f'{indent_str}{status_text} {key}\n' else: report += f'{indent_str}{status_text}\n' if value.get('declaration'): for d in value.get('declaration'): report += f'{indent_str}{d}\n' else: report += f'{indent_str}{key}\n' report += generate_text_report(value, level=level + 1) return report def generate_markdown_title(commit, run_id): pst_now = datetime.datetime.utcnow().astimezone( pytz.timezone('America/Los_Angeles')) return ( '## Apple API Diff Report\n' + 'Commit: %s\n' % commit + 'Last updated: %s \n' % pst_now.strftime('%a %b %e %H:%M %Z %G') + '**[View workflow logs & download artifacts]' + '(https://github.com/firebase/firebase-ios-sdk/actions/runs/%s)**\n\n' % run_id + '-----\n') def generate_markdown_report(diff, level=0): report = '' header_str = '#' * (level + 3) for key, value in diff.items(): if isinstance(value, dict): if key in ['api_types', 'apis', 'sub_apis']: report += generate_markdown_report(value, level=level) else: current_status = value.get('status') if current_status: # Module level: Always print out module name as title if level == 0: report += f'{header_str} {key} [{current_status}]\n' if current_status != STATUS_ERROR: # ADDED,REMOVED,MODIFIED report += '
\n\n' report += f'[{current_status}] {key}\n' report += '\n\n' declarations = value.get('declaration', []) sub_report = generate_text_report(value, level=1, print_key=False) detail = process_declarations(current_status, declarations, sub_report) report += f'```diff\n{detail}\n```\n\n
\n\n' else: # no diff at current level report += f'{header_str} {key}\n' report += generate_markdown_report(value, level=level + 1) # Module level: Always print out divider in the end if level == 0: report += '-----\n' return report def process_declarations(current_status, declarations, sub_report): """Diff syntax highlighting in Github Markdown.""" detail = '' if current_status == STATUS_MODIFIED: for line in (declarations + sub_report.split('\n')): if STATUS_ADD in line: prefix = '+ ' continue elif STATUS_REMOVED in line: prefix = '- ' continue if line: detail += f'{prefix}{line}\n' else: prefix = '+ ' if current_status == STATUS_ADD else '- ' for line in (declarations + sub_report.split('\n')): if line: detail += f'{prefix}{line}\n' return categorize_declarations(detail) def categorize_declarations(detail): """Categorize API info by Swift and Objective-C.""" lines = detail.split('\n') swift_lines = [line.replace('Swift', '') for line in lines if 'Swift' in line] objc_lines = [ line.replace('Objective-C', '') for line in lines if 'Objective-C' in line ] swift_detail = 'Swift:\n' + '\n'.join(swift_lines) if swift_lines else '' objc_detail = 'Objective-C:\n' + '\n'.join(objc_lines) if objc_lines else '' if not swift_detail and not objc_detail: return detail else: return f'{swift_detail}\n{objc_detail}'.strip() def parse_cmdline_args(): parser = argparse.ArgumentParser() parser.add_argument('-p', '--pr_branch') parser.add_argument('-b', '--base_branch') parser.add_argument('-c', '--commit') parser.add_argument('-i', '--run_id') args = parser.parse_args() return args if __name__ == '__main__': main()