#!/usr/bin/env python3

# Copyright (C) 2020-2025 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 COPYING3.  If not see
# <http://www.gnu.org/licenses/>.

import difflib
import os
import re
import sys
from collections import defaultdict

default_changelog_locations = {
    'c++tools',
    'config',
    'contrib',
    'contrib/header-tools',
    'contrib/reghunt',
    'contrib/regression',
    'fixincludes',
    'gcc/ada',
    'gcc/analyzer',
    'gcc/brig',
    'gcc/c',
    'gcc/c-family',
    'gcc',
    'gcc/cobol',
    'gcc/cp',
    'gcc/d',
    'gcc/fortran',
    'gcc/go',
    'gcc/jit',
    'gcc/lto',
    'gcc/m2',
    'gcc/objc',
    'gcc/objcp',
    'gcc/po',
    'gcc/rust',
    'gcc/testsuite',
    'gnattools',
    'gotools',
    'include',
    'intl',
    'libada',
    'libatomic',
    'libbacktrace',
    'libcc1',
    'libcody',
    'libcpp',
    'libcpp/po',
    'libdecnumber',
    'libffi',
    'libgcc',
    'libgcc/config/avr/libf7',
    'libgcc/config/libbid',
    'libgcobol',
    'libgfortran',
    'libgm2',
    'libgomp',
    'libgrust',
    'libhsail-rt',
    'libiberty',
    'libitm',
    'libobjc',
    'libphobos',
    'libquadmath',
    'libsanitizer',
    'libssp',
    'libstdc++-v3',
    'libvtv',
    'lto-plugin',
    'maintainer-scripts',
    'zlib'}

bug_components = {
    'ada',
    'analyzer',
    'boehm-gc',
    'bootstrap',
    'c',
    'c++',
    'cobol',
    'd',
    'debug',
    'demangler',
    'driver',
    'fastjar',
    'fortran',
    'gcov-profile',
    'go',
    'hsa',
    'inline-asm',
    'ipa',
    'java',
    'jit',
    'libbacktrace',
    'libcc1',
    'libf2c',
    'libffi',
    'libfortran',
    'libgcc',
    'libgcj',
    'libgdiagnostics',
    'libgomp',
    'libitm',
    'libobjc',
    'libquadmath',
    'libstdc++',
    'lto',
    'middle-end',
    'modula2',
    'objc',
    'objc++',
    'other',
    'pch',
    'pending',
    'plugins',
    'preprocessor',
    'regression',
    'rtl-optimization',
    'rust',
    'sanitizer',
    'sarif-replay',
    'spam',
    'target',
    'testsuite',
    'translation',
    'tree-optimization',
    'web'}

ignored_prefixes = {
    'gcc/d/dmd/',
    'gcc/go/gofrontend/',
    'gcc/testsuite/gdc.test/',
    'gcc/testsuite/go.test/test/',
    'libffi/',
    'libgo/',
    'libphobos/libdruntime/',
    'libphobos/src/',
    'libsanitizer/',
    }

wildcard_prefixes = {
    'gcc/testsuite/',
    'libstdc++-v3/doc/html/',
    'libstdc++-v3/testsuite/'
    }

misc_files = {
    'gcc/DATESTAMP',
    'gcc/BASE-VER',
    'gcc/DEV-PHASE'
    }

author_line_regex = \
        re.compile(r'^(?P<datetime>\d{4}-\d{2}-\d{2})\ {2}(?P<name>.*  <.*>)')
additional_author_regex = re.compile(r'^\t(?P<spaces>\ *)?(?P<name>.*  <.*>)')
changelog_regex = re.compile(r'^(?:[fF]or +)?([a-z0-9+-/]*)ChangeLog:?')
subject_pr_regex = re.compile(r'(^|\W)PR\s+(?P<component>[a-zA-Z0-9+-]+)/(?P<pr>\d{4,7})')
subject_pr2_regex = re.compile(r'[(\[]PR\s*(?P<pr>\d{4,7})[)\]]')
pr_regex = re.compile(r'\tPR (?P<component>[a-z0-9+-]+\/)?(?P<pr>[0-9]+)$')
dr_regex = re.compile(r'\tDR ([0-9]+)$')
star_prefix_regex = re.compile(r'\t\*(?P<spaces>\ *)(?P<content>.*)')
end_of_location_regex = re.compile(r'[\[<(:]')
item_empty_regex = re.compile(r'\t(\* \S+ )?\(\S+\):\s*$')
item_parenthesis_regex = re.compile(r'\t(\*|\(\S+\):)')
revert_regex = re.compile(r'This reverts commit (?P<hash>[0-9a-f]+)\.$')
cherry_pick_regex = re.compile(r'cherry picked from commit (?P<hash>\w+)')

LINE_LIMIT = 100
TAB_WIDTH = 8
CO_AUTHORED_BY_PREFIX = 'co-authored-by: '

REVIEW_PREFIXES = ('reviewed-by: ', 'reviewed-on: ', 'signed-off-by: ',
                   'acked-by: ', 'tested-by: ', 'reported-by: ',
                   'suggested-by: ')
DATE_FORMAT = '%Y-%m-%d'


def decode_path(path):
    # When core.quotepath is true (default value), utf8 chars are encoded like:
    # "b/ko\304\215ka.txt"
    #
    # The upstream bug is fixed:
    # https://github.com/gitpython-developers/GitPython/issues/1099
    #
    # but we still need a workaround for older versions of the library.
    # Please take a look at the explanation of the transformation:
    # https://stackoverflow.com/questions/990169/how-do-convert-unicode-escape-sequences-to-unicode-characters-in-a-python-string

    if path.startswith('"') and path.endswith('"'):
        return (path.strip('"').encode('utf8').decode('unicode-escape')
                .encode('latin-1').decode('utf8'))
    else:
        return path


class Error:
    def __init__(self, message, line=None, details=None):
        self.message = message
        self.line = line
        self.details = details

    def __repr__(self):
        s = self.message
        if self.line:
            s += ': "%s"' % self.line
        return s


class ChangeLogEntry:
    def __init__(self, folder, authors, prs):
        self.folder = folder
        # The 'list.copy()' function is not available before Python 3.3
        self.author_lines = list(authors)
        self.initial_prs = list(prs)
        self.prs = list(prs)
        self.lines = []
        self.files = []
        self.file_patterns = []
        self.parentheses_stack = []

    def parse_file_names(self):
        # Whether the content currently processed is between a star prefix the
        # end of the file list: a colon or an open paren.
        in_location = False

        for line in self.lines:
            # If this line matches the star prefix, start the location
            # processing on the information that follows the star.
            # Note that we need to skip macro names that can be in form of:
            #
            # * config/i386/i386.md (*fix_trunc<mode>_i387_1,
            # *add<mode>3_ne, *add<mode>3_eq_0, *add<mode>3_ne_0,
            # *fist<mode>2_<rounding>_1, *<code><mode>3_1):
            #
            m = star_prefix_regex.match(line)
            if m and len(m.group('spaces')) == 1:
                in_location = True
                line = m.group('content')

            if in_location:
                # Strip everything that is not a filename in "line":
                # entities "(NAME)", cases "<PATTERN>", conditions
                # "[COND]", entry text (the colon, if present, and
                # anything that follows it).
                m = end_of_location_regex.search(line)
                if m:
                    line = line[:m.start()]
                    in_location = False

                # At this point, all that's left is a list of filenames
                # separated by commas and whitespaces.
                for file in line.split(','):
                    file = file.strip()
                    if file:
                        if file.endswith('*'):
                            self.file_patterns.append(file[:-1])
                        else:
                            self.files.append(file)

    @property
    def datetime(self):
        for author in self.author_lines:
            if author[1]:
                return author[1]
        return None

    @property
    def authors(self):
        return [author_line[0] for author_line in self.author_lines]

    @property
    def is_empty(self):
        return not self.lines and self.prs == self.initial_prs

    def contains_author(self, author):
        for author_lines in self.author_lines:
            if author_lines[0] == author:
                return True
        return False


class GitInfo:
    def __init__(self, hexsha, date, author, lines, modified_files):
        self.hexsha = hexsha
        self.date = date
        self.author = author
        self.lines = lines
        self.modified_files = modified_files


class GitCommit:
    def __init__(self, info, commit_to_info_hook=None, ref_name=None):
        self.original_info = info
        self.info = info
        self.message = None
        self.changes = None
        self.changelog_entries = []
        self.errors = []
        self.warnings = []
        self.top_level_authors = []
        self.co_authors = []
        self.top_level_prs = []
        self.subject_prs = set()
        self.cherry_pick_commit = None
        self.revert_commit = None
        self.commit_to_info_hook = commit_to_info_hook
        self.init_changelog_locations(ref_name)

        # Skip Update copyright years commits
        if self.info.lines and self.info.lines[0] == 'Update copyright years.':
            return

        if self.info.lines and len(self.info.lines) > 1 and self.info.lines[1]:
            self.errors.append(Error('Expected empty second line in commit message', info.lines[0]))

        # Identify first if the commit is a Revert commit
        for line in self.info.lines:
            m = revert_regex.fullmatch(line)
            if m:
                self.revert_commit = m.group('hash')
                break
        if self.revert_commit:
            # The following happens for get_email.py:
            if not self.commit_to_info_hook:
                self.warnings.append(f"Invoked script can not obtain info about "
                                     f"reverted commits such as '{self.revert_commit}'")
                return
            self.info = self.commit_to_info_hook(self.revert_commit)
            if not self.info:
                self.errors.append(Error('Cannot find to-be-reverted commit', self.revert_commit))
                return

        self.check_commit_email()

        # Extract PR numbers form the subject line
        # Match either [PRnnnn] / (PRnnnn) or PR component/nnnn
        if self.info.lines and not self.revert_commit:
            self.subject_prs = {m.group('pr') for m in subject_pr2_regex.finditer(info.lines[0])}
            for m in subject_pr_regex.finditer(info.lines[0]):
                if not m.group('component') in bug_components:
                    self.errors.append(Error('invalid PR component in subject', info.lines[0]))
                self.subject_prs.add(m.group('pr'))

        # Allow complete deletion of ChangeLog files in a commit
        project_files = [f for f in self.info.modified_files
                         if (self.is_changelog_filename(f[0], allow_suffix=True) and f[1] != 'D')
                         or f[0] in misc_files]
        ignored_files = [f for f in self.info.modified_files
                         if self.in_ignored_location(f[0])]
        if len(project_files) == len(self.info.modified_files):
            # All modified files are only MISC files
            return
        elif project_files:
            err = 'ChangeLog, DATESTAMP, BASE-VER and DEV-PHASE updates ' \
                  'should be done separately from normal commits\n' \
                  '(note: ChangeLog entries will be automatically ' \
                  'added by a cron job)'
            self.errors.append(Error(err))
            return

        all_are_ignored = (len(project_files) + len(ignored_files)
                           == len(self.info.modified_files))
        self.parse_lines(all_are_ignored)
        if self.changes:
            self.parse_changelog()
            self.parse_file_names()
            self.check_for_empty_description()
            self.check_for_broken_parentheses()
            self.deduce_changelog_locations()
            self.check_file_patterns()
            self.check_line_start()
            if not self.errors:
                self.check_mentioned_files()
                self.check_for_correct_changelog()
        if self.subject_prs:
            self.errors.append(Error('PR %s in subject but not in changelog' %
                                     ', '.join(self.subject_prs), self.info.lines[0]))

    @property
    def success(self):
        return not self.errors

    @property
    def new_files(self):
        return [x[0] for x in self.info.modified_files if x[1] == 'A']

    @classmethod
    def is_changelog_filename(cls, path, allow_suffix=False):
        basename = os.path.basename(path)
        if basename == 'ChangeLog':
            return True
        elif allow_suffix and basename.startswith('ChangeLog'):
            return True
        else:
            return False

    def find_changelog_location(self, name):
        if name.startswith('\t'):
            name = name[1:]
        if name.endswith(':'):
            name = name[:-1]
        if name.endswith('/'):
            name = name[:-1]
        return name if name in self.changelog_locations else None

    @classmethod
    def format_git_author(cls, author):
        assert '<' in author
        return author.replace('<', ' <')

    @classmethod
    def parse_git_name_status(cls, string):
        modified_files = []
        for entry in string.split('\n'):
            parts = entry.split('\t')
            t = parts[0]
            if t == 'A' or t == 'D' or t == 'M':
                modified_files.append((parts[1], t))
            elif t.startswith('R'):
                modified_files.append((parts[1], 'D'))
                modified_files.append((parts[2], 'A'))
        return modified_files

    def init_changelog_locations(self, ref_name):
        self.changelog_locations = list(default_changelog_locations)
        if ref_name:
            version = sys.maxsize
            if 'releases/gcc-' in ref_name:
                version = int(ref_name.split('-')[-1])
            if version >= 12:
                # HSA and BRIG were removed in GCC 12
                self.changelog_locations.remove('gcc/brig')
                self.changelog_locations.remove('libhsail-rt')

    def parse_lines(self, all_are_ignored):
        body = self.info.lines

        for i, b in enumerate(body):
            if not b:
                continue
            if (changelog_regex.match(b) or self.find_changelog_location(b)
                    or star_prefix_regex.match(b) or pr_regex.match(b)
                    or dr_regex.match(b) or author_line_regex.match(b)
                    or b.lower().startswith(CO_AUTHORED_BY_PREFIX)):
                self.changes = body[i:]
                return
        if not all_are_ignored:
            self.errors.append(Error('cannot find a ChangeLog location in '
                                     'message'))

    def parse_changelog(self):
        last_entry = None
        will_deduce = False
        for line in self.changes:
            if not line:
                if last_entry and will_deduce:
                    last_entry = None
                continue
            if line != line.rstrip():
                self.errors.append(Error('trailing whitespace', line))
            if len(line.replace('\t', ' ' * TAB_WIDTH)) > LINE_LIMIT:
                # support long filenames
                if not line.startswith('\t* ') or not line.endswith(':') or ' ' in line[3:-1]:
                    self.errors.append(Error('line exceeds %d character limit'
                                             % LINE_LIMIT, line))
            m = changelog_regex.match(line)
            if m:
                last_entry = ChangeLogEntry(m.group(1).rstrip('/'),
                                            self.top_level_authors,
                                            self.top_level_prs)
                self.changelog_entries.append(last_entry)
            elif self.find_changelog_location(line):
                last_entry = ChangeLogEntry(self.find_changelog_location(line),
                                            self.top_level_authors,
                                            self.top_level_prs)
                self.changelog_entries.append(last_entry)
            else:
                author_tuple = None
                pr_line = None
                if author_line_regex.match(line):
                    m = author_line_regex.match(line)
                    author_tuple = (m.group('name'), m.group('datetime'))
                elif additional_author_regex.match(line):
                    m = additional_author_regex.match(line)
                    if len(m.group('spaces')) != 4:
                        msg = 'additional author must be indented with '\
                              'one tab and four spaces'
                        self.errors.append(Error(msg, line))
                    else:
                        author_tuple = (m.group('name'), None)
                elif pr_regex.match(line):
                    m = pr_regex.match(line)
                    component = m.group('component')
                    pr = m.group('pr')
                    if not component:
                        self.errors.append(Error('missing PR component', line))
                        continue
                    elif not component[:-1] in bug_components:
                        self.errors.append(Error('invalid PR component', line))
                        continue
                    else:
                        pr_line = line.lstrip()
                    if pr in self.subject_prs:
                        self.subject_prs.remove(pr)
                elif dr_regex.match(line):
                    pr_line = line.lstrip()

                lowered_line = line.lower()
                if lowered_line.startswith(CO_AUTHORED_BY_PREFIX):
                    name = line[len(CO_AUTHORED_BY_PREFIX):]
                    author = self.format_git_author(name)
                    self.co_authors.append(author)
                    continue
                elif lowered_line.startswith(REVIEW_PREFIXES):
                    continue
                else:
                    m = cherry_pick_regex.search(line)
                    if m:
                        commit = m.group('hash')
                        if self.cherry_pick_commit:
                            msg = 'multiple cherry pick lines'
                            self.errors.append(Error(msg, line))
                        else:
                            self.cherry_pick_commit = commit
                        continue

                # ChangeLog name will be deduced later
                if not last_entry:
                    if author_tuple:
                        self.top_level_authors.append(author_tuple)
                        continue
                    elif pr_line:
                        # append to top_level_prs only when we haven't met
                        # a ChangeLog entry
                        if (pr_line not in self.top_level_prs
                                and not self.changelog_entries):
                            self.top_level_prs.append(pr_line)
                        continue
                    else:
                        last_entry = ChangeLogEntry(None,
                                                    self.top_level_authors,
                                                    self.top_level_prs)
                        self.changelog_entries.append(last_entry)
                        will_deduce = True
                elif author_tuple:
                    if not last_entry.contains_author(author_tuple[0]):
                        last_entry.author_lines.append(author_tuple)
                    continue

                if not line.startswith('\t'):
                    err = Error('line should start with a tab', line)
                    self.errors.append(err)
                elif pr_line:
                    last_entry.prs.append(pr_line)
                else:
                    m = star_prefix_regex.match(line)
                    if m:
                        if (len(m.group('spaces')) != 1 and
                                not last_entry.parentheses_stack):
                            msg = 'one space should follow asterisk'
                            self.errors.append(Error(msg, line))
                        else:
                            content = m.group('content')
                            parts = content.split(':')
                            if len(parts) > 1:
                                for needle in ('()', '[]', '<>'):
                                    if ' ' + needle in parts[0]:
                                        msg = f'empty group "{needle}" found'
                                        self.errors.append(Error(msg, line))
                            last_entry.lines.append(line)
                            self.process_parentheses(last_entry, line)
                    else:
                        if last_entry.is_empty:
                            msg = 'first line should start with a tab, ' \
                                  'an asterisk and a space'
                            self.errors.append(Error(msg, line))
                        else:
                            last_entry.lines.append(line)
                            self.process_parentheses(last_entry, line)

    def process_parentheses(self, last_entry, line):
        for c in line:
            if c == '(':
                last_entry.parentheses_stack.append(line)
            elif c == ')':
                if not last_entry.parentheses_stack:
                    msg = 'bad wrapping of parenthesis'
                    self.errors.append(Error(msg, line))
                else:
                    del last_entry.parentheses_stack[-1]

    def parse_file_names(self):
        for entry in self.changelog_entries:
            entry.parse_file_names()

    def check_file_patterns(self):
        for entry in self.changelog_entries:
            for pattern in entry.file_patterns:
                name = os.path.join(entry.folder, pattern)
                if not [name.startswith(pr) for pr in wildcard_prefixes]:
                    msg = 'unsupported wildcard prefix'
                    self.errors.append(Error(msg, name))

    def check_for_empty_description(self):
        for entry in self.changelog_entries:
            for i, line in enumerate(entry.lines):
                if (item_empty_regex.match(line) and
                    (i == len(entry.lines) - 1
                     or not entry.lines[i+1].strip()
                     or item_parenthesis_regex.match(entry.lines[i+1]))):
                    msg = 'missing description of a change'
                    self.errors.append(Error(msg, line))

    def check_for_broken_parentheses(self):
        for entry in self.changelog_entries:
            if entry.parentheses_stack:
                msg = 'bad parentheses wrapping'
                self.errors.append(Error(msg, entry.parentheses_stack[-1]))

    def check_line_start(self):
        for entry in self.changelog_entries:
            for line in entry.lines:
                if line.startswith('\t '):
                    msg = 'extra space after tab'
                    self.errors.append(Error(msg, line))

    def get_file_changelog_location(self, changelog_file):
        for file in self.info.modified_files:
            if file[0] == changelog_file:
                # root ChangeLog file
                return ''
            index = file[0].find('/' + changelog_file)
            if index != -1:
                return file[0][:index]
        return None

    def deduce_changelog_locations(self):
        for entry in self.changelog_entries:
            if entry.folder is None:
                changelog = None
                for file in entry.files:
                    location = self.get_file_changelog_location(file)
                    if (location == ''
                       or (location and location in self.changelog_locations)):
                        if changelog and changelog != location:
                            msg = 'could not deduce ChangeLog file, ' \
                                  'not unique location'
                            self.errors.append(Error(msg))
                            return
                        changelog = location
                if changelog is not None:
                    entry.folder = changelog
                else:
                    msg = 'could not deduce ChangeLog file'
                    self.errors.append(Error(msg))

    @classmethod
    def in_ignored_location(cls, path):
        for ignored in ignored_prefixes:
            if path.startswith(ignored):
                return True
        return False

    def get_changelog_by_path(self, path):
        components = path.split('/')
        while components:
            if '/'.join(components) in self.changelog_locations:
                break
            components = components[:-1]
        return '/'.join(components)

    def check_mentioned_files(self):
        folder_count = len([x.folder for x in self.changelog_entries])
        assert folder_count == len(self.changelog_entries)

        mentioned_files = set()
        mentioned_patterns = []
        used_patterns = set()
        for entry in self.changelog_entries:
            if not entry.files and not entry.file_patterns:
                msg = 'no files mentioned for ChangeLog in directory'
                self.errors.append(Error(msg, entry.folder))
            assert not entry.folder.endswith('/')
            for file in entry.files:
                if not self.is_changelog_filename(file):
                    item = os.path.join(entry.folder, file)
                    if item in mentioned_files:
                        msg = 'same file specified multiple times'
                        self.errors.append(Error(msg, file))
                    else:
                        mentioned_files.add(item)
            for pattern in entry.file_patterns:
                mentioned_patterns.append(os.path.join(entry.folder, pattern))

        cand = [x[0] for x in self.info.modified_files
                if not self.is_changelog_filename(x[0])]
        changed_files = set(cand)
        for file in sorted(mentioned_files - changed_files):
            msg = 'unchanged file mentioned in a ChangeLog'
            candidates = difflib.get_close_matches(file, changed_files, 1)
            details = None
            if candidates:
                msg += f' (did you mean "{candidates[0]}"?)'
                details = '\n'.join(difflib.Differ().compare([file], [candidates[0]])).rstrip()
            self.errors.append(Error(msg, file, details))
        auto_add_warnings = defaultdict(list)
        for file in sorted(changed_files - mentioned_files):
            if not self.in_ignored_location(file):
                if file in self.new_files:
                    changelog_location = self.get_changelog_by_path(file)
                    # Python2: we cannot use next(filter(...))
                    entries = filter(lambda x: x.folder == changelog_location,
                                     self.changelog_entries)
                    entries = list(entries)
                    entry = entries[0] if entries else None
                    if not entry:
                        prs = self.top_level_prs
                        if not prs:
                            # if all ChangeLog entries have identical PRs
                            # then use them
                            if self.changelog_entries:
                                prs = self.changelog_entries[0].prs
                                for entry in self.changelog_entries:
                                    if entry.prs != prs:
                                        prs = []
                                        break
                        entry = ChangeLogEntry(changelog_location,
                                               self.top_level_authors,
                                               prs)
                        self.changelog_entries.append(entry)
                    # strip prefix of the file
                    assert file.startswith(entry.folder)
                    # do not allow auto-addition of New files
                    # for the top-level folder
                    if entry.folder:
                        file = file[len(entry.folder):].lstrip('/')
                        entry.lines.append('\t* %s: New file.' % file)
                        entry.files.append(file)
                        auto_add_warnings[entry.folder].append(file)
                    else:
                        msg = 'new file in the top-level folder not mentioned in a ChangeLog'
                        self.errors.append(Error(msg, file))
                else:
                    used_pattern = [p for p in mentioned_patterns
                                    if file.startswith(p)]
                    used_pattern = used_pattern[0] if used_pattern else None
                    if used_pattern:
                        used_patterns.add(used_pattern)
                    else:
                        msg = 'changed file not mentioned in a ChangeLog'
                        self.errors.append(Error(msg, file))

        for pattern in mentioned_patterns:
            if pattern not in used_patterns:
                error = "pattern doesn't match any changed files"
                self.errors.append(Error(error, pattern))
        for entry, val in auto_add_warnings.items():
            if len(val) == 1:
                self.warnings.append(f"Auto-added new file '{entry}/{val[0]}'")
            else:
                self.warnings.append(f"Auto-added {len(val)} new files in '{entry}'")

    def check_for_correct_changelog(self):
        for entry in self.changelog_entries:
            for file in entry.files:
                full_path = os.path.join(entry.folder, file)
                changelog_location = self.get_changelog_by_path(full_path)
                if changelog_location != entry.folder:
                    msg = 'wrong ChangeLog location "%s", should be "%s"'
                    err = Error(msg % (entry.folder, changelog_location), file)
                    self.errors.append(err)

    @classmethod
    def format_authors_in_changelog(cls, authors, timestamp, prefix=''):
        output = ''
        for i, author in enumerate(authors):
            if i == 0:
                output += '%s%s  %s\n' % (prefix, timestamp, author)
            else:
                output += '%s\t    %s\n' % (prefix, author)
        output += '\n'
        return output

    def to_changelog_entries(self, use_commit_ts=False):
        current_timestamp = self.info.date.strftime(DATE_FORMAT)
        for entry in self.changelog_entries:
            output = ''
            timestamp = entry.datetime
            if self.revert_commit:
                timestamp = current_timestamp
                orig_date = self.original_info.date
                current_timestamp = orig_date.strftime(DATE_FORMAT)
            elif self.cherry_pick_commit:
                info = (self.commit_to_info_hook
                        and self.commit_to_info_hook(self.cherry_pick_commit))
                # it can happen that it is a cherry-pick for a different
                # repository
                if info:
                    timestamp = info.date.strftime(DATE_FORMAT)
                else:
                    if self.commit_to_info_hook:
                        self.warnings.append(f"Cherry-picked commit not found: '{self.cherry_pick_commit}'")
                    else:
                        self.warnings.append(f"Invoked script can not obtain info about "
                                             f"cherry-picked commits such as '{self.revert_commit}'")
                    timestamp = current_timestamp
            elif not timestamp or use_commit_ts:
                timestamp = current_timestamp
            authors = entry.authors if entry.authors else [self.info.author]
            # add Co-Authored-By authors to all ChangeLog entries
            for author in self.co_authors:
                if author not in authors:
                    authors.append(author)

            if self.cherry_pick_commit or self.revert_commit:
                original_author = self.original_info.author
                output += self.format_authors_in_changelog([original_author],
                                                           current_timestamp)
                if self.revert_commit:
                    output += '\tRevert:\n'
                else:
                    output += '\tBackported from master:\n'
                output += self.format_authors_in_changelog(authors,
                                                           timestamp, '\t')
            else:
                output += self.format_authors_in_changelog(authors, timestamp)
            for pr in entry.prs:
                output += '\t%s\n' % pr
            for line in entry.lines:
                output += line + '\n'
            yield (entry.folder, output.rstrip())

    def print_output(self):
        for entry, output in self.to_changelog_entries():
            print('------ %s/ChangeLog ------ ' % entry)
            print(output)

    def print_errors(self):
        print('Errors:')
        for error in self.errors:
            print(error)

    def print_warnings(self):
        if self.warnings:
            print('Warnings:')
            for warning in self.warnings:
                print(warning)

    def check_commit_email(self):
        # Parse 'Martin Liska  <mliska@suse.cz>'
        email = self.info.author.split(' ')[-1].strip('<>')

        # Verify that all characters are ASCII
        # TODO: Python 3.7 provides a nicer function: isascii
        if len(email) != len(email.encode()):
            self.errors.append(Error(f'non-ASCII characters in git commit email address ({email})'))