1#!/usr/bin/env python3
2# Verifies whether commit messages adhere to the standards.
3# Checks the author name and email and invokes the tools/commit-msg script.
4# Copy this into .git/hooks/post-commit
5#
6# Copyright (c) 2018 Peter Wu <peter@lekensteyn.nl>
7#
8# Wireshark - Network traffic analyzer
9# By Gerald Combs <gerald@wireshark.org>
10# Copyright 1998 Gerald Combs
11#
12# SPDX-License-Identifier: GPL-2.0-or-later
13
14from __future__ import print_function
15
16import argparse
17import difflib
18import json
19import os
20import subprocess
21import sys
22import tempfile
23import urllib.request
24import re
25
26
27parser = argparse.ArgumentParser()
28parser.add_argument('commit', nargs='?', default='HEAD',
29                    help='Commit ID to be checked (default %(default)s)')
30parser.add_argument('--commitmsg', help='commit-msg check', action='store')
31
32
33def print_git_user_instructions():
34    print('To configure your name and email for git, run:')
35    print('')
36    print('  git config --global user.name "Your Name"')
37    print('  git config --global user.email "you@example.com"')
38    print('')
39    print('After that update the author of your latest commit with:')
40    print('')
41    print('  git commit --amend --reset-author --no-edit')
42    print('')
43
44
45def verify_name(name):
46    name = name.lower().strip()
47    forbidden_names = ('unknown', 'root', 'user', 'your name')
48    if name in forbidden_names:
49        return False
50    # Warn about names without spaces. Sometimes it is a mistake where the
51    # developer accidentally committed using the system username.
52    if ' ' not in name:
53        print("WARNING: name '%s' does not contain a space." % (name,))
54        print_git_user_instructions()
55    return True
56
57
58def verify_email(email):
59    email = email.lower().strip()
60    try:
61        user, host = email.split('@')
62    except ValueError:
63        # Lacks a '@' (e.g. a plain domain or "foo[AT]example.com")
64        return False
65    tld = host.split('.')[-1]
66
67    # localhost, localhost.localdomain, my.local etc.
68    if 'local' in tld:
69        return False
70
71    # Possibly an IP address
72    if tld.isdigit():
73        return False
74
75    # forbid code.wireshark.org. Submissions could be submitted by other
76    # addresses if one would like to remain anonymous.
77    if host.endswith('.wireshark.org'):
78        return False
79
80    # For documentation purposes only.
81    if host == 'example.com':
82        return False
83
84    # 'peter-ubuntu32.(none)'
85    if '(none)' in host:
86        return False
87
88    return True
89
90
91def tools_dir():
92    if __file__.endswith('.py'):
93        # Assume direct invocation from tools directory
94        return os.path.dirname(__file__)
95    # Otherwise it is a git hook. To support git worktrees, do not manually look
96    # for the .git directory, but query the actual top level instead.
97    cmd = ['git', 'rev-parse', '--show-toplevel']
98    srcdir = subprocess.check_output(cmd, universal_newlines=True).strip()
99    return os.path.join(srcdir, 'tools')
100
101
102def extract_subject(subject):
103    '''Extracts the original subject (ignoring the Revert prefix).'''
104    subject = subject.rstrip('\r\n')
105    prefix = 'Revert "'
106    suffix = '"'
107    while subject.startswith(prefix) and subject.endswith(suffix):
108        subject = subject[len(prefix):-len(suffix)]
109    return subject
110
111
112def verify_body(body):
113    bodynocomments = re.sub('^#.*$', '', body, flags=re.MULTILINE)
114    old_lines = bodynocomments.splitlines(True)
115    is_good = True
116    if len(old_lines) >= 2 and old_lines[1].strip():
117        print('ERROR: missing blank line after the first subject line.')
118        is_good = False
119    cleaned_subject = extract_subject(old_lines[0])
120    if len(cleaned_subject) > 80:
121        # Note that this check is also invoked by the commit-msg hook.
122        print('Warning: keep lines in the commit message under 80 characters.')
123        is_good = False
124    if not is_good:
125        print('''
126Please rewrite your commit message to our standards, matching this format:
127
128    component: a very brief summary of the change
129
130    A commit message should start with a brief summary, followed by a single
131    blank line and an optional longer description. If the change is specific to
132    a single protocol, start the summary line with the abbreviated name of the
133    protocol and a colon.
134
135    Use paragraphs to improve readability. Limit each line to 80 characters.
136
137''')
138    if any(line.startswith('Bug:') or line.startswith('Ping-Bug:') for line in old_lines):
139        sys.stderr.write('''
140To close an issue, use "Closes #1234" or "Fixes #1234" instead of "Bug: 1234".
141To reference an issue, use "related to #1234" instead of "Ping-Bug: 1234". See
142https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically
143for details.
144''')
145        return False
146
147    # Cherry-picking can add an extra newline, which we'll allow.
148    cp_line = '\n(cherry picked from commit'
149    body = body.replace('\n' + cp_line, cp_line)
150
151    try:
152        cmd = ['git', 'stripspace']
153        newbody = subprocess.check_output(cmd, input=body, universal_newlines=True)
154    except OSError as ex:
155        print('Warning: unable to invoke git stripspace: %s' % (ex,))
156        return is_good
157    if newbody != body:
158        new_lines = newbody.splitlines(True)
159        diff = difflib.unified_diff(old_lines, new_lines,
160                                    fromfile='OLD/.git/COMMIT_EDITMSG',
161                                    tofile='NEW/.git/COMMIT_EDITMSG')
162        # Clearly mark trailing whitespace (GNU patch supports such comments).
163        diff = [
164            '# NOTE: trailing space on the next line\n%s' % (line,)
165            if len(line) > 2 and line[-2].isspace() else line
166            for line in diff
167        ]
168        print('The commit message does not follow our standards.')
169        print('Please rewrite it (there are likely whitespace issues):')
170        print('')
171        print(''.join(diff))
172        return False
173    return is_good
174
175
176
177def verify_merge_request():
178    # Not needed if/when https://gitlab.com/gitlab-org/gitlab/-/issues/23308 is fixed.
179    gitlab_api_pfx = "https://gitlab.com/api/v4"
180    # gitlab.com/wireshark/wireshark = 7898047
181    project_id = os.getenv('CI_MERGE_REQUEST_PROJECT_ID')
182    ansi_csi = '\x1b['
183    ansi_codes = {
184        'black_white': ansi_csi + '30;47m',
185        'bold_red': ansi_csi + '31;1m', # gitlab-runner errors
186        'reset': ansi_csi + '0m'
187    }
188    m_r_iid = os.getenv('CI_MERGE_REQUEST_IID')
189    if project_id is None or m_r_iid is None:
190        print("This doesn't appear to be a merge request. CI_MERGE_REQUEST_PROJECT_ID={}, CI_MERGE_REQUEST_IID={}".format(project_id, m_r_iid))
191        return True
192
193    m_r_url = '{}/projects/{}/merge_requests/{}'.format(gitlab_api_pfx, project_id, m_r_iid)
194    req = urllib.request.Request(m_r_url)
195    # print('req', repr(req), m_r_url)
196    with urllib.request.urlopen(req) as resp:
197        resp_json = resp.read().decode('utf-8')
198        # print('resp', resp_json)
199        m_r_attrs = json.loads(resp_json)
200        try:
201            if not m_r_attrs['allow_collaboration']:
202                print('''\
203{bold_red}ERROR:{reset} Please edit your merge request and make sure the setting
204    {black_white}✅ Allow commits from members who can merge to the target branch{reset}
205is checked so that maintainers can rebase your change and make minor edits.\
206'''.format(**ansi_codes))
207                return False
208        except KeyError:
209            sys.stderr.write('This appears to be a merge request, but we were not able to fetch the "Allow commits" status\n')
210    return True
211
212
213def main():
214    args = parser.parse_args()
215    commit = args.commit
216
217    # If called from commit-msg script, just validate that part and return.
218    if args.commitmsg:
219        try:
220            with open(args.commitmsg) as f:
221                return 0 if verify_body(f.read()) else 1
222        except:
223            print("Couldn't verify body of message from file '", + args.commitmsg + "'");
224            return 1
225
226
227    if(os.getenv('CI_MERGE_REQUEST_EVENT_TYPE') == 'merge_train'):
228        print("If we were on the love train, people all over the world would be joining hands for this merge request.\nInstead, we're on a merge train so we're skipping commit validation checks. ")
229        return 0
230
231    cmd = ['git', 'show', '--no-patch',
232           '--format=%h%n%an%n%ae%n%B', commit, '--']
233    output = subprocess.check_output(cmd, universal_newlines=True)
234    # For some reason there is always an additional LF in the output, drop it.
235    if output.endswith('\n\n'):
236        output = output[:-1]
237    abbrev, author_name, author_email, body = output.split('\n', 3)
238    subject = body.split('\n', 1)[0]
239
240    # If called directly (from the tools directory), print the commit that was
241    # being validated. If called from a git hook (without .py extension), try to
242    # remain silent unless there are issues.
243    if __file__.endswith('.py'):
244        print('Checking commit: %s %s' % (abbrev, subject))
245
246    exit_code = 0
247    if not verify_name(author_name):
248        print('Disallowed author name: {}'.format(author_name))
249        exit_code = 1
250
251    if not verify_email(author_email):
252        print('Disallowed author email address: {}'.format(author_email))
253        exit_code = 1
254
255    if exit_code:
256        print_git_user_instructions()
257
258    if not verify_body(body):
259        exit_code = 1
260
261    if not verify_merge_request():
262        exit_code = 1
263
264    return exit_code
265
266
267if __name__ == '__main__':
268    try:
269        sys.exit(main())
270    except subprocess.CalledProcessError as ex:
271        print('\n%s' % ex)
272        sys.exit(ex.returncode)
273    except KeyboardInterrupt:
274        sys.exit(130)
275