#!/usr/bin/env python3
# Copyright lowRISC contributors (OpenTitan project).
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
# SPDX-License-Identifier: Apache-2.0

import argparse
import re
import sys

from git import Repo

error_msg_prefix = 'ERROR: '
warning_msg_prefix = 'WARNING: '

# Maximum length of the summary line in the commit message (the first line)
# There is no hard limit, but a typical convention is to keep this line at or
# below 50 characters, with occasional outliers.
COMMIT_MSG_MAX_SUMMARY_LEN = 100


def error(msg, commit=None):
    full_msg = msg
    if commit:
        full_msg = "Commit %s: %s" % (commit.hexsha, msg)
    print(error_msg_prefix + full_msg, file=sys.stderr)


def warning(msg, commit=None):
    full_msg = msg
    if commit:
        full_msg = "Commit %s: %s" % (commit.hexsha, msg)
    print(warning_msg_prefix + full_msg, file=sys.stderr)


def lint_commit_author(commit):
    success = True
    if commit.author.email.endswith('users.noreply.github.com'):
        error(
            f'Commit author has no valid email address set: '  # noqa: F541
            '{commit.author.email!r}. '
            'Use "git config user.email user@example.com" to '
            'set a valid email address, then update the commit '
            'with "git rebase -i" and/or '
            '"git commit --amend --signoff --reset-author". '
            'Also check your GitHub settings at '
            'https://github.com/settings/emails: your email address '
            'must be verified, and the option "Keep my email address '
            'private" must be disabled. '
            'This command will also sign off your commit indicating agreement '
            'to the Contributor License Agreement. See CONTRIBUTING.md for '
            'more details.', commit)
        success = False

    if ' ' not in commit.author.name:
        warning(
            f'The commit author name {commit.author.name!r} contains no space. '
            'Use "git config user.name \'Johnny English\'" to '
            'set your real name, and update the commit with "git rebase -i " '
            'and/or "git commit --amend --signoff --reset-author". '
            'This command will also sign off your commit indicating agreement '
            'to the Contributor License Agreement. See CONTRIBUTING.md for '
            'more details.', commit)
        # A warning doesn't fail lint.

    return success


def lint_commit_message(commit):
    success = True
    lines = commit.message.splitlines()

    # Check length of summary line.
    summary_line_len = len(lines[0])
    if summary_line_len > COMMIT_MSG_MAX_SUMMARY_LEN:
        error(
            "The summary line in the commit message is %d characters long; "
            "only %d characters are allowed." %
            (summary_line_len, COMMIT_MSG_MAX_SUMMARY_LEN), commit)
        success = False

    # Check for an empty line separating the summary line from the long
    # description.
    if len(lines) > 1 and lines[1] != "":
        error(
            "The second line of a commit message must be empty, as it "
            "separates the summary from the long description.", commit)
        success = False

    # Check that the commit message contains at least one Signed-off-by line
    # that matches the author name and email. There might be other signoffs (if
    # there are multiple authors). We don't have any requirements about those
    # at the moment and just pass them through.
    signoff_lines = []
    signoff_pfx = 'Signed-off-by: '
    for idx, line in enumerate(lines):
        if not line.startswith(signoff_pfx):
            continue

        signoff_body = line[len(signoff_pfx):]
        match = re.match(r'[^<]+ <[^>]*>$', signoff_body)
        if match is None:
            error('Commit has Signed-off-by line {!r}, but the second part '
                  'is not of the required form. It should be of the form '
                  '"Signed-off-by: NAME <EMAIL>".'
                  .format(line))
            success = False

        signoff_lines.append(line)

    expected_signoff_line = ("Signed-off-by: {} <{}>"
                             .format(commit.author.name,
                                     commit.author.email))
    signoff_req_msg = ('The commit message must contain a Signed-off-by line '
                       'that matches the commit author name and email, '
                       'indicating agreement to the Contributor License '
                       'Agreement. See CONTRIBUTING.md for more details. '
                       'You can use "git commit --signoff" to ask git to add '
                       'this line for you.')

    if not signoff_lines:
        error('Commit has no Signed-off-by line. ' + signoff_req_msg)
        success = False
    elif expected_signoff_line not in signoff_lines:
        error(('Commit has one or more Signed-off-by lines, but not the one '
               'we expect. We expected to find "{}". '
               .format(expected_signoff_line)) +
              signoff_req_msg)
        success = False

    return success


def lint_commit(commit):
    success = True
    if not lint_commit_author(commit):
        success = False
    if not lint_commit_message(commit):
        success = False
    return success


def main():
    global error_msg_prefix
    global warning_msg_prefix

    parser = argparse.ArgumentParser(
        description='Check commit metadata for common mistakes')
    parser.add_argument('--error-msg-prefix',
                        default=error_msg_prefix,
                        required=False,
                        help='string to prepend to all error messages')
    parser.add_argument('--warning-msg-prefix',
                        default=warning_msg_prefix,
                        required=False,
                        help='string to prepend to all warning messages')
    parser.add_argument('--no-merges',
                        required=False,
                        action="store_true",
                        help='do not check commits with more than one parent')
    parser.add_argument('commit_range',
                        metavar='commit-range',
                        help=('commit range to check '
                              '(must be understood by git log)'))
    args = parser.parse_args()

    error_msg_prefix = args.error_msg_prefix
    warning_msg_prefix = args.warning_msg_prefix

    lint_successful = True

    repo = Repo()
    commits = repo.iter_commits(args.commit_range)
    for commit in commits:
        print("Checking commit %s" % commit.hexsha)
        is_merge = len(commit.parents) > 1
        if is_merge and args.no_merges:
            print("Skipping merge commit.")
            continue

        if not lint_commit(commit):
            lint_successful = False

    if not lint_successful:
        error('Commit lint failed.')
        sys.exit(1)


if __name__ == '__main__':
    main()
