| #!/usr/bin/env python3 |
| |
| # Copyright (C) 2020 Free Software Foundation, Inc. |
| # |
| # This file is part of GCC. |
| # |
| # GCC is free software; you can redistribute it and/or modify |
| # it under the terms of the GNU General Public License as published by |
| # the Free Software Foundation; either version 3, or (at your option) |
| # any later version. |
| # |
| # GCC is distributed in the hope that it will be useful, |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| # GNU General Public License for more details. |
| # |
| # You should have received a copy of the GNU General Public License |
| # along with GCC; see the file COPYING. If not, write to |
| # the Free Software Foundation, 51 Franklin Street, Fifth Floor, |
| # Boston, MA 02110-1301, USA. |
| |
| # This script parses a .diff file generated with 'diff -up' or 'diff -cp' |
| # and adds a skeleton ChangeLog file to the file. It does not try to be |
| # too smart when parsing function names, but it produces a reasonable |
| # approximation. |
| # |
| # Author: Martin Liska <mliska@suse.cz> |
| |
| import argparse |
| import datetime |
| import json |
| import os |
| import re |
| import subprocess |
| import sys |
| from itertools import takewhile |
| |
| import requests |
| |
| from unidiff import PatchSet |
| |
| LINE_LIMIT = 100 |
| TAB_WIDTH = 8 |
| CO_AUTHORED_BY_PREFIX = 'co-authored-by: ' |
| |
| pr_regex = re.compile(r'(\/(\/|\*)|[Cc*!])\s+(?P<pr>PR [a-z+-]+\/[0-9]+)') |
| prnum_regex = re.compile(r'PR (?P<comp>[a-z+-]+)/(?P<num>[0-9]+)') |
| dr_regex = re.compile(r'(\/(\/|\*)|[Cc*!])\s+(?P<dr>DR [0-9]+)') |
| dg_regex = re.compile(r'{\s+dg-(error|warning)') |
| pr_filename_regex = re.compile(r'(^|[\W_])[Pp][Rr](?P<pr>\d{4,})') |
| identifier_regex = re.compile(r'^([a-zA-Z0-9_#].*)') |
| comment_regex = re.compile(r'^\/\*') |
| struct_regex = re.compile(r'^(class|struct|union|enum)\s+' |
| r'(GTY\(.*\)\s+)?([a-zA-Z0-9_]+)') |
| macro_regex = re.compile(r'#\s*(define|undef)\s+([a-zA-Z0-9_]+)') |
| super_macro_regex = re.compile(r'^DEF[A-Z0-9_]+\s*\(([a-zA-Z0-9_]+)') |
| fn_regex = re.compile(r'([a-zA-Z_][^()\s]*)\s*\([^*]') |
| template_and_param_regex = re.compile(r'<[^<>]*>') |
| md_def_regex = re.compile(r'\(define.*\s+"(.*)"') |
| bugzilla_url = 'https://gcc.gnu.org/bugzilla/rest.cgi/bug?id=%s&' \ |
| 'include_fields=summary,component' |
| |
| function_extensions = {'.c', '.cpp', '.C', '.cc', '.h', '.inc', '.def', '.md'} |
| |
| # NB: Makefile.in isn't listed as it's not always generated. |
| generated_files = {'aclocal.m4', 'config.h.in', 'configure'} |
| |
| help_message = """\ |
| Generate ChangeLog template for PATCH. |
| PATCH must be generated using diff(1)'s -up or -cp options |
| (or their equivalent in git). |
| """ |
| |
| script_folder = os.path.realpath(__file__) |
| root = os.path.dirname(os.path.dirname(script_folder)) |
| |
| |
| def find_changelog(path): |
| folder = os.path.split(path)[0] |
| while True: |
| if os.path.exists(os.path.join(root, folder, 'ChangeLog')): |
| return folder |
| folder = os.path.dirname(folder) |
| if folder == '': |
| return folder |
| raise AssertionError() |
| |
| |
| def extract_function_name(line): |
| if comment_regex.match(line): |
| return None |
| m = struct_regex.search(line) |
| if m: |
| # Struct declaration |
| return m.group(1) + ' ' + m.group(3) |
| m = macro_regex.search(line) |
| if m: |
| # Macro definition |
| return m.group(2) |
| m = super_macro_regex.search(line) |
| if m: |
| # Supermacro |
| return m.group(1) |
| m = fn_regex.search(line) |
| if m: |
| # Discard template and function parameters. |
| fn = m.group(1) |
| fn = re.sub(template_and_param_regex, '', fn) |
| return fn.rstrip() |
| return None |
| |
| |
| def try_add_function(functions, line): |
| fn = extract_function_name(line) |
| if fn and fn not in functions: |
| functions.append(fn) |
| return bool(fn) |
| |
| |
| def sort_changelog_files(changed_file): |
| return (changed_file.is_added_file, changed_file.is_removed_file) |
| |
| |
| def get_pr_titles(prs): |
| output = [] |
| for idx, pr in enumerate(prs): |
| pr_id = pr.split('/')[-1] |
| r = requests.get(bugzilla_url % pr_id) |
| bugs = r.json()['bugs'] |
| if len(bugs) == 1: |
| prs[idx] = 'PR %s/%s' % (bugs[0]['component'], pr_id) |
| out = '%s - %s\n' % (prs[idx], bugs[0]['summary']) |
| if out not in output: |
| output.append(out) |
| if output: |
| output.append('') |
| return '\n'.join(output) |
| |
| |
| def append_changelog_line(out, relative_path, text): |
| line = f'\t* {relative_path}:' |
| if len(line.replace('\t', ' ' * TAB_WIDTH) + ' ' + text) <= LINE_LIMIT: |
| out += f'{line} {text}\n' |
| else: |
| out += f'{line}\n' |
| out += f'\t{text}\n' |
| return out |
| |
| |
| def get_rel_path_if_prefixed(path, folder): |
| if path.startswith(folder): |
| return path[len(folder):].lstrip('/') |
| else: |
| return path |
| |
| |
| def generate_changelog(data, no_functions=False, fill_pr_titles=False, |
| additional_prs=None): |
| global prs |
| prs = [] |
| |
| changelogs = {} |
| changelog_list = [] |
| out = '' |
| diff = PatchSet(data) |
| |
| if additional_prs: |
| for apr in additional_prs: |
| if not apr.startswith('PR ') and '/' in apr: |
| apr = 'PR ' + apr |
| if apr not in prs: |
| prs.append(apr) |
| for file in diff: |
| # skip files that can't be parsed |
| if file.path == '/dev/null': |
| continue |
| changelog = find_changelog(file.path) |
| if changelog not in changelogs: |
| changelogs[changelog] = [] |
| changelog_list.append(changelog) |
| changelogs[changelog].append(file) |
| |
| # Extract PR entries from newly added tests |
| if 'testsuite' in file.path and file.is_added_file: |
| # Only search first ten lines as later lines may |
| # contains commented code which a note that it |
| # has not been tested due to a certain PR or DR. |
| this_file_prs = [] |
| for line in list(file)[0][0:10]: |
| m = pr_regex.search(line.value) |
| if m: |
| pr = m.group('pr') |
| if pr not in prs: |
| prs.append(pr) |
| this_file_prs.append(pr.split('/')[-1]) |
| else: |
| m = dr_regex.search(line.value) |
| if m: |
| dr = m.group('dr') |
| if dr not in prs: |
| prs.append(dr) |
| this_file_prs.append(dr.split('/')[-1]) |
| elif dg_regex.search(line.value): |
| # Found dg-warning/dg-error line |
| break |
| # PR number in the file name |
| fname = os.path.basename(file.path) |
| m = pr_filename_regex.search(fname) |
| if m: |
| pr = m.group('pr') |
| pr2 = 'PR ' + pr |
| if pr not in this_file_prs and pr2 not in prs: |
| prs.append(pr2) |
| |
| if fill_pr_titles: |
| out += get_pr_titles(prs) |
| |
| # print list of PR entries before ChangeLog entries |
| if prs: |
| if not out: |
| out += '\n' |
| for pr in prs: |
| out += '\t%s\n' % pr |
| out += '\n' |
| |
| # sort ChangeLog so that 'testsuite' is at the end |
| for changelog in sorted(changelog_list, key=lambda x: 'testsuite' in x): |
| files = changelogs[changelog] |
| out += '%s:\n' % os.path.join(changelog, 'ChangeLog') |
| out += '\n' |
| # new and deleted files should be at the end |
| for file in sorted(files, key=sort_changelog_files): |
| assert file.path.startswith(changelog) |
| in_tests = 'testsuite' in changelog or 'testsuite' in file.path |
| relative_path = get_rel_path_if_prefixed(file.path, changelog) |
| functions = [] |
| if file.is_added_file: |
| msg = 'New test.' if in_tests else 'New file.' |
| out = append_changelog_line(out, relative_path, msg) |
| elif file.is_removed_file: |
| out = append_changelog_line(out, relative_path, 'Removed.') |
| elif hasattr(file, 'is_rename') and file.is_rename: |
| # A file can be theoretically moved to a location that |
| # belongs to a different ChangeLog. Let user fix it. |
| # |
| # Since unidiff 0.7.0, path.file == path.target_file[2:], |
| # it used to be path.source_file[2:] |
| relative_path = get_rel_path_if_prefixed(file.source_file[2:], |
| changelog) |
| out = append_changelog_line(out, relative_path, 'Moved to...') |
| new_path = get_rel_path_if_prefixed(file.target_file[2:], |
| changelog) |
| out += f'\t* {new_path}: ...here.\n' |
| elif os.path.basename(file.path) in generated_files: |
| out += '\t* %s: Regenerate.\n' % (relative_path) |
| append_changelog_line(out, relative_path, 'Regenerate.') |
| else: |
| if not no_functions: |
| for hunk in file: |
| # Do not add function names for testsuite files |
| extension = os.path.splitext(relative_path)[1] |
| if not in_tests and extension in function_extensions: |
| last_fn = None |
| modified_visited = False |
| success = False |
| for line in hunk: |
| m = identifier_regex.match(line.value) |
| if line.is_added or line.is_removed: |
| # special-case definition in .md files |
| m2 = md_def_regex.match(line.value) |
| if extension == '.md' and m2: |
| fn = m2.group(1) |
| if fn not in functions: |
| functions.append(fn) |
| last_fn = None |
| success = True |
| |
| if not line.value.strip(): |
| continue |
| modified_visited = True |
| if m and try_add_function(functions, |
| m.group(1)): |
| last_fn = None |
| success = True |
| elif line.is_context: |
| if last_fn and modified_visited: |
| try_add_function(functions, last_fn) |
| last_fn = None |
| modified_visited = False |
| success = True |
| elif m: |
| last_fn = m.group(1) |
| modified_visited = False |
| if not success: |
| try_add_function(functions, |
| hunk.section_header) |
| if functions: |
| out += '\t* %s (%s):\n' % (relative_path, functions[0]) |
| for fn in functions[1:]: |
| out += '\t(%s):\n' % fn |
| else: |
| out += '\t* %s:\n' % relative_path |
| out += '\n' |
| return out |
| |
| |
| def update_copyright(data): |
| current_timestamp = datetime.datetime.now().strftime('%Y-%m-%d') |
| username = subprocess.check_output('git config user.name', shell=True, |
| encoding='utf8').strip() |
| email = subprocess.check_output('git config user.email', shell=True, |
| encoding='utf8').strip() |
| |
| changelogs = set() |
| diff = PatchSet(data) |
| |
| for file in diff: |
| changelog = os.path.join(find_changelog(file.path), 'ChangeLog') |
| if changelog not in changelogs: |
| changelogs.add(changelog) |
| with open(changelog) as f: |
| content = f.read() |
| with open(changelog, 'w+') as f: |
| f.write(f'{current_timestamp} {username} <{email}>\n\n') |
| f.write('\tUpdate copyright years.\n\n') |
| f.write(content) |
| |
| |
| def skip_line_in_changelog(line): |
| if line.lower().startswith(CO_AUTHORED_BY_PREFIX) or line.startswith('#'): |
| return False |
| return True |
| |
| |
| if __name__ == '__main__': |
| extra_args = os.getenv('GCC_MKLOG_ARGS') |
| if extra_args: |
| sys.argv += json.loads(extra_args) |
| |
| parser = argparse.ArgumentParser(description=help_message) |
| parser.add_argument('input', nargs='?', |
| help='Patch file (or missing, read standard input)') |
| parser.add_argument('-b', '--pr-numbers', action='store', |
| type=lambda arg: arg.split(','), nargs='?', |
| help='Add the specified PRs (comma separated)') |
| parser.add_argument('-s', '--no-functions', action='store_true', |
| help='Do not generate function names in ChangeLogs') |
| parser.add_argument('-p', '--fill-up-bug-titles', action='store_true', |
| help='Download title of mentioned PRs') |
| parser.add_argument('-d', '--directory', |
| help='Root directory where to search for ChangeLog ' |
| 'files') |
| parser.add_argument('-c', '--changelog', |
| help='Append the ChangeLog to a git commit message ' |
| 'file') |
| parser.add_argument('--update-copyright', action='store_true', |
| help='Update copyright in ChangeLog files') |
| args = parser.parse_args() |
| if args.input == '-': |
| args.input = None |
| if args.directory: |
| root = args.directory |
| |
| data = open(args.input) if args.input else sys.stdin |
| if args.update_copyright: |
| update_copyright(data) |
| else: |
| output = generate_changelog(data, args.no_functions, |
| args.fill_up_bug_titles, args.pr_numbers) |
| if args.changelog: |
| lines = open(args.changelog).read().split('\n') |
| start = list(takewhile(skip_line_in_changelog, lines)) |
| end = lines[len(start):] |
| with open(args.changelog, 'w') as f: |
| if not start or not start[0]: |
| if len(prs) == 1: |
| # initial commit subject line 'component: [PRnnnnn]' |
| m = prnum_regex.match(prs[0]) |
| if m: |
| title = f'{m.group("comp")}: [PR{m.group("num")}]' |
| start.insert(0, title) |
| if start: |
| # append empty line |
| if start[-1] != '': |
| start.append('') |
| else: |
| # append 2 empty lines |
| start = 2 * [''] |
| f.write('\n'.join(start)) |
| f.write('\n') |
| f.write(output) |
| f.write('\n'.join(end)) |
| else: |
| print(output, end='') |