| # Copyright 2016 The Android Open Source Project |
| # |
| # 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. |
| |
| """Functions that implement the actual checks.""" |
| |
| import fnmatch |
| import json |
| import os |
| import platform |
| import re |
| import sys |
| from typing import Callable, NamedTuple |
| |
| _path = os.path.realpath(__file__ + '/../..') |
| if sys.path[0] != _path: |
| sys.path.insert(0, _path) |
| del _path |
| |
| # pylint: disable=wrong-import-position |
| import rh.git |
| import rh.results |
| import rh.utils |
| |
| |
| class Placeholders(object): |
| """Holder class for replacing ${vars} in arg lists. |
| |
| To add a new variable to replace in config files, just add it as a @property |
| to this class using the form. So to add support for BIRD: |
| @property |
| def var_BIRD(self): |
| return <whatever this is> |
| |
| You can return either a string or an iterable (e.g. a list or tuple). |
| """ |
| |
| def __init__(self, diff=()): |
| """Initialize. |
| |
| Args: |
| diff: The list of files that changed. |
| """ |
| self.diff = diff |
| |
| def expand_vars(self, args): |
| """Perform place holder expansion on all of |args|. |
| |
| Args: |
| args: The args to perform expansion on. |
| |
| Returns: |
| The updated |args| list. |
| """ |
| all_vars = set(self.vars()) |
| replacements = dict((var, self.get(var)) for var in all_vars) |
| |
| ret = [] |
| for arg in args: |
| if arg.endswith('${PREUPLOAD_FILES_PREFIXED}'): |
| if arg == '${PREUPLOAD_FILES_PREFIXED}': |
| assert len(ret) > 1, ('PREUPLOAD_FILES_PREFIXED cannot be ' |
| 'the 1st or 2nd argument') |
| prev_arg = ret[-1] |
| ret = ret[0:-1] |
| for file in self.get('PREUPLOAD_FILES'): |
| ret.append(prev_arg) |
| ret.append(file) |
| else: |
| prefix = arg[0:-len('${PREUPLOAD_FILES_PREFIXED}')] |
| ret.extend( |
| prefix + file for file in self.get('PREUPLOAD_FILES')) |
| else: |
| # First scan for exact matches |
| for key, val in replacements.items(): |
| var = '${' + key + '}' |
| if arg == var: |
| if isinstance(val, str): |
| ret.append(val) |
| else: |
| ret.extend(val) |
| # We break on first hit to avoid double expansion. |
| break |
| else: |
| # If no exact matches, do an inline replacement. |
| def replace(m): |
| val = self.get(m.group(1)) |
| if isinstance(val, str): |
| return val |
| return ' '.join(val) |
| ret.append(re.sub(r'\$\{(' + '|'.join(all_vars) + r')\}', |
| replace, arg)) |
| return ret |
| |
| @classmethod |
| def vars(cls): |
| """Yield all replacement variable names.""" |
| for key in dir(cls): |
| if key.startswith('var_'): |
| yield key[4:] |
| |
| def get(self, var): |
| """Helper function to get the replacement |var| value.""" |
| return getattr(self, f'var_{var}') |
| |
| @property |
| def var_PREUPLOAD_COMMIT_MESSAGE(self): |
| """The git commit message.""" |
| return os.environ.get('PREUPLOAD_COMMIT_MESSAGE', '') |
| |
| @property |
| def var_PREUPLOAD_COMMIT(self): |
| """The git commit sha1.""" |
| return os.environ.get('PREUPLOAD_COMMIT', '') |
| |
| @property |
| def var_PREUPLOAD_FILES(self): |
| """List of files modified in this git commit.""" |
| return [x.file for x in self.diff if x.status != 'D'] |
| |
| @property |
| def var_REPO_PATH(self): |
| """The path to the project relative to the root""" |
| return os.environ.get('REPO_PATH', '') |
| |
| @property |
| def var_REPO_PROJECT(self): |
| """The name of the project""" |
| return os.environ.get('REPO_PROJECT', '') |
| |
| @property |
| def var_REPO_ROOT(self): |
| """The root of the repo (sub-manifest) checkout.""" |
| return rh.git.find_repo_root() |
| |
| @property |
| def var_REPO_OUTER_ROOT(self): |
| """The root of the repo (outer) checkout.""" |
| return rh.git.find_repo_root(outer=True) |
| |
| @property |
| def var_BUILD_OS(self): |
| """The build OS (see _get_build_os_name for details).""" |
| return _get_build_os_name() |
| |
| |
| class ExclusionScope(object): |
| """Exclusion scope for a hook. |
| |
| An exclusion scope can be used to determine if a hook has been disabled for |
| a specific project. |
| """ |
| |
| def __init__(self, scope): |
| """Initialize. |
| |
| Args: |
| scope: A list of shell-style wildcards (fnmatch) or regular |
| expression. Regular expressions must start with the ^ character. |
| """ |
| self._scope = [] |
| for path in scope: |
| if path.startswith('^'): |
| self._scope.append(re.compile(path)) |
| else: |
| self._scope.append(path) |
| |
| def __contains__(self, proj_dir): |
| """Checks if |proj_dir| matches the excluded paths. |
| |
| Args: |
| proj_dir: The relative path of the project. |
| """ |
| for exclusion_path in self._scope: |
| if hasattr(exclusion_path, 'match'): |
| if exclusion_path.match(proj_dir): |
| return True |
| elif fnmatch.fnmatch(proj_dir, exclusion_path): |
| return True |
| return False |
| |
| |
| class HookOptions(object): |
| """Holder class for hook options.""" |
| |
| def __init__(self, name, args, tool_paths): |
| """Initialize. |
| |
| Args: |
| name: The name of the hook. |
| args: The override commandline arguments for the hook. |
| tool_paths: A dictionary with tool names to paths. |
| """ |
| self.name = name |
| self._args = args |
| self._tool_paths = tool_paths |
| |
| @staticmethod |
| def expand_vars(args, diff=()): |
| """Perform place holder expansion on all of |args|.""" |
| replacer = Placeholders(diff=diff) |
| return replacer.expand_vars(args) |
| |
| def args(self, default_args=(), diff=()): |
| """Gets the hook arguments, after performing place holder expansion. |
| |
| Args: |
| default_args: The list to return if |self._args| is empty. |
| diff: The list of files that changed in the current commit. |
| |
| Returns: |
| A list with arguments. |
| """ |
| args = self._args |
| if not args: |
| args = default_args |
| |
| return self.expand_vars(args, diff=diff) |
| |
| def tool_path(self, tool_name): |
| """Gets the path in which the |tool_name| executable can be found. |
| |
| This function performs expansion for some place holders. If the tool |
| does not exist in the overridden |self._tool_paths| dictionary, the tool |
| name will be returned and will be run from the user's $PATH. |
| |
| Args: |
| tool_name: The name of the executable. |
| |
| Returns: |
| The path of the tool with all optional place holders expanded. |
| """ |
| assert tool_name in TOOL_PATHS |
| if tool_name not in self._tool_paths: |
| return TOOL_PATHS[tool_name] |
| |
| tool_path = os.path.normpath(self._tool_paths[tool_name]) |
| return self.expand_vars([tool_path])[0] |
| |
| |
| class CallableHook(NamedTuple): |
| """A callable hook.""" |
| name: str |
| hook: Callable |
| scope: ExclusionScope |
| |
| |
| def _run(cmd, **kwargs): |
| """Helper command for checks that tend to gather output.""" |
| kwargs.setdefault('combine_stdout_stderr', True) |
| kwargs.setdefault('capture_output', True) |
| kwargs.setdefault('check', False) |
| # Make sure hooks run with stdin disconnected to avoid accidentally |
| # interactive tools causing pauses. |
| kwargs.setdefault('input', '') |
| return rh.utils.run(cmd, **kwargs) |
| |
| |
| def _match_regex_list(subject, expressions): |
| """Try to match a list of regular expressions to a string. |
| |
| Args: |
| subject: The string to match regexes on. |
| expressions: An iterable of regular expressions to check for matches with. |
| |
| Returns: |
| Whether the passed in subject matches any of the passed in regexes. |
| """ |
| for expr in expressions: |
| if re.search(expr, subject): |
| return True |
| return False |
| |
| |
| def _filter_diff(diff, include_list, exclude_list=()): |
| """Filter out files based on the conditions passed in. |
| |
| Args: |
| diff: list of diff objects to filter. |
| include_list: list of regex that when matched with a file path will cause |
| it to be added to the output list unless the file is also matched with |
| a regex in the exclude_list. |
| exclude_list: list of regex that when matched with a file will prevent it |
| from being added to the output list, even if it is also matched with a |
| regex in the include_list. |
| |
| Returns: |
| A list of filepaths that contain files matched in the include_list and not |
| in the exclude_list. |
| """ |
| filtered = [] |
| for d in diff: |
| if (d.status != 'D' and |
| _match_regex_list(d.file, include_list) and |
| not _match_regex_list(d.file, exclude_list)): |
| # We've got a match! |
| filtered.append(d) |
| return filtered |
| |
| |
| def _get_build_os_name(): |
| """Gets the build OS name. |
| |
| Returns: |
| A string in a format usable to get prebuilt tool paths. |
| """ |
| system = platform.system() |
| if 'Darwin' in system or 'Macintosh' in system: |
| return 'darwin-x86' |
| |
| # TODO: Add more values if needed. |
| return 'linux-x86' |
| |
| |
| def _check_cmd(hook_name, project, commit, cmd, fixup_cmd=None, **kwargs): |
| """Runs |cmd| and returns its result as a HookCommandResult.""" |
| return [rh.results.HookCommandResult(hook_name, project, commit, |
| _run(cmd, **kwargs), |
| fixup_cmd=fixup_cmd)] |
| |
| |
| # Where helper programs exist. |
| TOOLS_DIR = os.path.realpath(__file__ + '/../../tools') |
| |
| def get_helper_path(tool): |
| """Return the full path to the helper |tool|.""" |
| return os.path.join(TOOLS_DIR, tool) |
| |
| |
| def check_custom(project, commit, _desc, diff, options=None, **kwargs): |
| """Run a custom hook.""" |
| return _check_cmd(options.name, project, commit, options.args((), diff), |
| **kwargs) |
| |
| |
| def check_aosp_license(project, commit, _desc, diff, options=None): |
| """Checks that if all new added files has AOSP licenses""" |
| |
| exclude_dir_args = [x for x in options.args() |
| if x.startswith('--exclude-dirs=')] |
| exclude_dirs = [x[len('--exclude-dirs='):].split(',') |
| for x in exclude_dir_args] |
| exclude_list = [fr'^{x}/.*$' for dir_list in exclude_dirs for x in dir_list] |
| |
| # Filter diff based on extension. |
| extensions = frozenset(( |
| # Coding languages and scripts. |
| 'c', |
| 'cc', |
| 'cpp', |
| 'h', |
| 'java', |
| 'kt', |
| 'rs', |
| 'py', |
| 'sh', |
| |
| # Build and config files. |
| 'bp', |
| 'mk', |
| 'xml', |
| )) |
| diff = _filter_diff(diff, [r'\.(' + '|'.join(extensions) + r')$'], exclude_list) |
| |
| # Only check the new-added files. |
| diff = [d for d in diff if d.status == 'A'] |
| |
| if not diff: |
| return None |
| |
| cmd = [get_helper_path('check_aosp_license.py'), '--commit-hash', commit] |
| cmd += HookOptions.expand_vars(('${PREUPLOAD_FILES}',), diff) |
| return _check_cmd('aosp_license', project, commit, cmd) |
| |
| |
| def check_bpfmt(project, commit, _desc, diff, options=None): |
| """Checks that Blueprint files are formatted with bpfmt.""" |
| filtered = _filter_diff(diff, [r'\.bp$']) |
| if not filtered: |
| return None |
| |
| bpfmt = options.tool_path('bpfmt') |
| bpfmt_options = options.args((), filtered) |
| cmd = [bpfmt, '-d'] + bpfmt_options |
| fixup_cmd = [bpfmt, '-w'] |
| if '-s' in bpfmt_options: |
| fixup_cmd.append('-s') |
| fixup_cmd.append('--') |
| |
| ret = [] |
| for d in filtered: |
| data = rh.git.get_file_content(commit, d.file) |
| result = _run(cmd, input=data) |
| if result.stdout: |
| ret.append(rh.results.HookResult( |
| 'bpfmt', project, commit, |
| error=result.stdout, |
| files=(d.file,), |
| fixup_cmd=fixup_cmd)) |
| return ret |
| |
| |
| def check_checkpatch(project, commit, _desc, diff, options=None): |
| """Run |diff| through the kernel's checkpatch.pl tool.""" |
| tool = get_helper_path('checkpatch.pl') |
| cmd = ([tool, '-', '--root', project.dir] + |
| options.args(('--ignore=GERRIT_CHANGE_ID',), diff)) |
| return _check_cmd('checkpatch.pl', project, commit, cmd, |
| input=rh.git.get_patch(commit)) |
| |
| |
| def check_clang_format(project, commit, _desc, diff, options=None): |
| """Run git clang-format on the commit.""" |
| tool = get_helper_path('clang-format.py') |
| clang_format = options.tool_path('clang-format') |
| git_clang_format = options.tool_path('git-clang-format') |
| tool_args = (['--clang-format', clang_format, '--git-clang-format', |
| git_clang_format] + |
| options.args(('--style', 'file', '--commit', commit), diff)) |
| cmd = [tool] + tool_args |
| fixup_cmd = [tool, '--fix'] + tool_args |
| return _check_cmd('clang-format', project, commit, cmd, |
| fixup_cmd=fixup_cmd) |
| |
| |
| def check_google_java_format(project, commit, _desc, _diff, options=None): |
| """Run google-java-format on the commit.""" |
| include_dir_args = [x for x in options.args() |
| if x.startswith('--include-dirs=')] |
| include_dirs = [x[len('--include-dirs='):].split(',') |
| for x in include_dir_args] |
| patterns = [fr'^{x}/.*\.java$' for dir_list in include_dirs |
| for x in dir_list] |
| if not patterns: |
| patterns = [r'\.java$'] |
| |
| filtered = _filter_diff(_diff, patterns) |
| |
| if not filtered: |
| return None |
| |
| args = [x for x in options.args() if x not in include_dir_args] |
| |
| tool = get_helper_path('google-java-format.py') |
| google_java_format = options.tool_path('google-java-format') |
| google_java_format_diff = options.tool_path('google-java-format-diff') |
| tool_args = ['--google-java-format', google_java_format, |
| '--google-java-format-diff', google_java_format_diff, |
| '--commit', commit] + args |
| cmd = [tool] + tool_args + HookOptions.expand_vars( |
| ('${PREUPLOAD_FILES}',), filtered) |
| fixup_cmd = [tool, '--fix'] + tool_args |
| return [rh.results.HookCommandResult('google-java-format', project, commit, |
| _run(cmd), |
| files=[x.file for x in filtered], |
| fixup_cmd=fixup_cmd)] |
| |
| |
| def check_ktfmt(project, commit, _desc, diff, options=None): |
| """Checks that kotlin files are formatted with ktfmt.""" |
| |
| include_dir_args = [x for x in options.args() |
| if x.startswith('--include-dirs=')] |
| include_dirs = [x[len('--include-dirs='):].split(',') |
| for x in include_dir_args] |
| patterns = [fr'^{x}/.*\.kt$' for dir_list in include_dirs |
| for x in dir_list] |
| if not patterns: |
| patterns = [r'\.kt$'] |
| |
| filtered = _filter_diff(diff, patterns) |
| |
| if not filtered: |
| return None |
| |
| args = [x for x in options.args() if x not in include_dir_args] |
| |
| ktfmt = options.tool_path('ktfmt') |
| cmd = [ktfmt, '--dry-run'] + args + HookOptions.expand_vars( |
| ('${PREUPLOAD_FILES}',), filtered) |
| result = _run(cmd) |
| if result.stdout: |
| fixup_cmd = [ktfmt] + args |
| return [rh.results.HookResult( |
| 'ktfmt', project, commit, error='Formatting errors detected', |
| files=[x.file for x in filtered], fixup_cmd=fixup_cmd)] |
| return None |
| |
| |
| def check_commit_msg_bug_field(project, commit, desc, _diff, options=None): |
| """Check the commit message for a 'Bug:' or 'Fix:' line.""" |
| regex = r'^(Bug|Fix): (None|[0-9]+(, [0-9]+)*)$' |
| check_re = re.compile(regex) |
| |
| if options.args(): |
| raise ValueError('commit msg Bug check takes no options') |
| |
| found = [] |
| for line in desc.splitlines(): |
| if check_re.match(line): |
| found.append(line) |
| |
| if not found: |
| error = ( |
| 'Commit message is missing a "Bug:" line. It must match the\n' |
| f'following case-sensitive regex:\n\n {regex}' |
| ) |
| else: |
| return None |
| |
| return [rh.results.HookResult('commit msg: "Bug:" check', |
| project, commit, error=error)] |
| |
| |
| def check_commit_msg_changeid_field(project, commit, desc, _diff, options=None): |
| """Check the commit message for a 'Change-Id:' line.""" |
| field = 'Change-Id' |
| regex = fr'^{field}: I[a-f0-9]+$' |
| check_re = re.compile(regex) |
| |
| if options.args(): |
| raise ValueError(f'commit msg {field} check takes no options') |
| |
| found = [] |
| for line in desc.splitlines(): |
| if check_re.match(line): |
| found.append(line) |
| |
| if not found: |
| error = ( |
| f'Commit message is missing a "{field}:" line. It must match the\n' |
| f'following case-sensitive regex:\n\n {regex}' |
| ) |
| elif len(found) > 1: |
| error = (f'Commit message has too many "{field}:" lines. There can be ' |
| 'only one.') |
| else: |
| return None |
| |
| return [rh.results.HookResult(f'commit msg: "{field}:" check', |
| project, commit, error=error)] |
| |
| |
| PREBUILT_APK_MSG = """Commit message is missing required prebuilt APK |
| information. To generate the information, use the aapt tool to dump badging |
| information of the APKs being uploaded, specify where the APK was built, and |
| specify whether the APKs are suitable for release: |
| |
| for apk in $(find . -name '*.apk' | sort); do |
| echo "${apk}" |
| ${AAPT} dump badging "${apk}" | |
| grep -iE "(package: |sdkVersion:|targetSdkVersion:)" | |
| sed -e "s/' /'\\n/g" |
| echo |
| done |
| |
| It must match the following case-sensitive multiline regex searches: |
| |
| %s |
| |
| For more information, see go/platform-prebuilt and go/android-prebuilt. |
| |
| """ |
| |
| |
| def check_commit_msg_prebuilt_apk_fields(project, commit, desc, diff, |
| options=None): |
| """Check that prebuilt APK commits contain the required lines.""" |
| |
| if options.args(): |
| raise ValueError('prebuilt apk check takes no options') |
| |
| filtered = _filter_diff(diff, [r'\.apk$']) |
| if not filtered: |
| return None |
| |
| regexes = [ |
| r'^package: .*$', |
| r'^sdkVersion:.*$', |
| r'^targetSdkVersion:.*$', |
| r'^Built here:.*$', |
| (r'^This build IS( NOT)? suitable for' |
| r'( preview|( preview or)? public) release' |
| r'( but IS NOT suitable for public release)?\.$') |
| ] |
| |
| missing = [] |
| for regex in regexes: |
| if not re.search(regex, desc, re.MULTILINE): |
| missing.append(regex) |
| |
| if missing: |
| error = PREBUILT_APK_MSG % '\n '.join(missing) |
| else: |
| return None |
| |
| return [rh.results.HookResult('commit msg: "prebuilt apk:" check', |
| project, commit, error=error)] |
| |
| |
| TEST_MSG = """Commit message is missing a "Test:" line. It must match the |
| following case-sensitive regex: |
| |
| %s |
| |
| The Test: stanza is free-form and should describe how you tested your change. |
| As a CL author, you'll have a consistent place to describe the testing strategy |
| you use for your work. As a CL reviewer, you'll be reminded to discuss testing |
| as part of your code review, and you'll more easily replicate testing when you |
| patch in CLs locally. |
| |
| Some examples below: |
| |
| Test: make WITH_TIDY=1 mmma art |
| Test: make test-art |
| Test: manual - took a photo |
| Test: refactoring CL. Existing unit tests still pass. |
| |
| Check the git history for more examples. It's a free-form field, so we urge |
| you to develop conventions that make sense for your project. Note that many |
| projects use exact test commands, which are perfectly fine. |
| |
| Adding good automated tests with new code is critical to our goals of keeping |
| the system stable and constantly improving quality. Please use Test: to |
| highlight this area of your development. And reviewers, please insist on |
| high-quality Test: descriptions. |
| """ |
| |
| |
| def check_commit_msg_test_field(project, commit, desc, _diff, options=None): |
| """Check the commit message for a 'Test:' line.""" |
| field = 'Test' |
| regex = fr'^{field}: .*$' |
| check_re = re.compile(regex) |
| |
| if options.args(): |
| raise ValueError(f'commit msg {field} check takes no options') |
| |
| found = [] |
| for line in desc.splitlines(): |
| if check_re.match(line): |
| found.append(line) |
| |
| if not found: |
| error = TEST_MSG % (regex) |
| else: |
| return None |
| |
| return [rh.results.HookResult(f'commit msg: "{field}:" check', |
| project, commit, error=error)] |
| |
| |
| RELNOTE_MISSPELL_MSG = """Commit message contains something that looks |
| similar to the "Relnote:" tag. It must match the regex: |
| |
| %s |
| |
| The Relnote: stanza is free-form and should describe what developers need to |
| know about your change. |
| |
| Some examples below: |
| |
| Relnote: "Added a new API `Class#isBetter` to determine whether or not the |
| class is better" |
| Relnote: Fixed an issue where the UI would hang on a double tap. |
| |
| Check the git history for more examples. It's a free-form field, so we urge |
| you to develop conventions that make sense for your project. |
| """ |
| |
| RELNOTE_MISSING_QUOTES_MSG = """Commit message contains something that looks |
| similar to the "Relnote:" tag but might be malformatted. For multiline |
| release notes, you need to include a starting and closing quote. |
| |
| Multi-line Relnote example: |
| |
| Relnote: "Added a new API `Class#getSize` to get the size of the class. |
| This is useful if you need to know the size of the class." |
| |
| Single-line Relnote example: |
| |
| Relnote: Added a new API `Class#containsData` |
| """ |
| |
| RELNOTE_INVALID_QUOTES_MSG = """Commit message contains something that looks |
| similar to the "Relnote:" tag but might be malformatted. If you are using |
| quotes that do not mark the start or end of a Relnote, you need to escape them |
| with a backslash. |
| |
| Non-starting/non-ending quote Relnote examples: |
| |
| Relnote: "Fixed an error with `Class#getBar()` where \"foo\" would be returned |
| in edge cases." |
| Relnote: Added a new API to handle strings like \"foo\" |
| """ |
| |
| def check_commit_msg_relnote_field_format(project, commit, desc, _diff, |
| options=None): |
| """Check the commit for one correctly formatted 'Relnote:' line. |
| |
| Checks the commit message for two things: |
| (1) Checks for possible misspellings of the 'Relnote:' tag. |
| (2) Ensures that multiline release notes are properly formatted with a |
| starting quote and an endling quote. |
| (3) Checks that release notes that contain non-starting or non-ending |
| quotes are escaped with a backslash. |
| """ |
| field = 'Relnote' |
| regex_relnote = fr'^{field}:.*$' |
| check_re_relnote = re.compile(regex_relnote, re.IGNORECASE) |
| |
| if options.args(): |
| raise ValueError(f'commit msg {field} check takes no options') |
| |
| # Check 1: Check for possible misspellings of the `Relnote:` field. |
| |
| # Regex for misspelled fields. |
| possible_field_misspells = { |
| 'Relnotes', 'ReleaseNote', |
| 'Rel-note', 'Rel note', |
| 'rel-notes', 'releasenotes', |
| 'release-note', 'release-notes', |
| } |
| re_possible_field_misspells = '|'.join(possible_field_misspells) |
| regex_field_misspells = fr'^({re_possible_field_misspells}): .*$' |
| check_re_field_misspells = re.compile(regex_field_misspells, re.IGNORECASE) |
| |
| ret = [] |
| for line in desc.splitlines(): |
| if check_re_field_misspells.match(line): |
| error = RELNOTE_MISSPELL_MSG % (regex_relnote, ) |
| ret.append( |
| rh.results.HookResult( |
| f'commit msg: "{field}:" tag spelling error', |
| project, commit, error=error)) |
| |
| # Check 2: Check that multiline Relnotes are quoted. |
| |
| check_re_empty_string = re.compile(r'^$') |
| |
| # Regex to find other fields that could be used. |
| regex_other_fields = r'^[a-zA-Z0-9-]+:' |
| check_re_other_fields = re.compile(regex_other_fields) |
| |
| desc_lines = desc.splitlines() |
| for i, cur_line in enumerate(desc_lines): |
| # Look for a Relnote tag that is before the last line and |
| # lacking any quotes. |
| if (check_re_relnote.match(cur_line) and |
| i < len(desc_lines) - 1 and |
| '"' not in cur_line): |
| next_line = desc_lines[i + 1] |
| # Check that the next line does not contain any other field |
| # and it's not an empty string. |
| if (not check_re_other_fields.findall(next_line) and |
| not check_re_empty_string.match(next_line)): |
| ret.append( |
| rh.results.HookResult( |
| f'commit msg: "{field}:" tag missing quotes', |
| project, commit, error=RELNOTE_MISSING_QUOTES_MSG)) |
| break |
| |
| # Check 3: Check that multiline Relnotes contain matching quotes. |
| first_quote_found = False |
| second_quote_found = False |
| for cur_line in desc_lines: |
| contains_quote = '"' in cur_line |
| contains_field = check_re_other_fields.findall(cur_line) |
| # If we have found the first quote and another field, break and fail. |
| if first_quote_found and contains_field: |
| break |
| # If we have found the first quote, this line contains a quote, |
| # and this line is not another field, break and succeed. |
| if first_quote_found and contains_quote: |
| second_quote_found = True |
| break |
| # Check that the `Relnote:` tag exists and it contains a starting quote. |
| if check_re_relnote.match(cur_line) and contains_quote: |
| first_quote_found = True |
| # A single-line Relnote containing a start and ending triple quote |
| # is valid. |
| if cur_line.count('"""') == 2: |
| second_quote_found = True |
| break |
| # A single-line Relnote containing a start and ending quote |
| # is valid. |
| if cur_line.count('"') - cur_line.count('\\"') == 2: |
| second_quote_found = True |
| break |
| if first_quote_found != second_quote_found: |
| ret.append( |
| rh.results.HookResult( |
| f'commit msg: "{field}:" tag missing closing quote', |
| project, commit, error=RELNOTE_MISSING_QUOTES_MSG)) |
| |
| # Check 4: Check that non-starting or non-ending quotes are escaped with a |
| # backslash. |
| line_needs_checking = False |
| uses_invalid_quotes = False |
| for cur_line in desc_lines: |
| if check_re_other_fields.findall(cur_line): |
| line_needs_checking = False |
| on_relnote_line = check_re_relnote.match(cur_line) |
| # Determine if we are parsing the base `Relnote:` line. |
| if on_relnote_line and '"' in cur_line: |
| line_needs_checking = True |
| # We don't think anyone will type '"""' and then forget to |
| # escape it, so we're not checking for this. |
| if '"""' in cur_line: |
| break |
| if line_needs_checking: |
| stripped_line = re.sub(fr'^{field}:', '', cur_line, |
| flags=re.IGNORECASE).strip() |
| for i, character in enumerate(stripped_line): |
| if i == 0: |
| # Case 1: Valid quote at the beginning of the |
| # base `Relnote:` line. |
| if on_relnote_line: |
| continue |
| # Case 2: Invalid quote at the beginning of following |
| # lines, where we are not terminating the release note. |
| if character == '"' and stripped_line != '"': |
| uses_invalid_quotes = True |
| break |
| # Case 3: Check all other cases. |
| if (character == '"' |
| and 0 < i < len(stripped_line) - 1 |
| and stripped_line[i-1] != '"' |
| and stripped_line[i-1] != "\\"): |
| uses_invalid_quotes = True |
| break |
| |
| if uses_invalid_quotes: |
| ret.append(rh.results.HookResult( |
| f'commit msg: "{field}:" tag using unescaped quotes', |
| project, commit, error=RELNOTE_INVALID_QUOTES_MSG)) |
| return ret |
| |
| |
| RELNOTE_REQUIRED_CURRENT_TXT_MSG = """\ |
| Commit contains a change to current.txt or public_plus_experimental_current.txt, |
| but the commit message does not contain the required `Relnote:` tag. It must |
| match the regex: |
| |
| %s |
| |
| The Relnote: stanza is free-form and should describe what developers need to |
| know about your change. If you are making infrastructure changes, you |
| can set the Relnote: stanza to be "N/A" for the commit to not be included |
| in release notes. |
| |
| Some examples: |
| |
| Relnote: "Added a new API `Class#isBetter` to determine whether or not the |
| class is better" |
| Relnote: Fixed an issue where the UI would hang on a double tap. |
| Relnote: N/A |
| |
| Check the git history for more examples. |
| """ |
| |
| def check_commit_msg_relnote_for_current_txt(project, commit, desc, diff, |
| options=None): |
| """Check changes to current.txt contain the 'Relnote:' stanza.""" |
| field = 'Relnote' |
| regex = fr'^{field}: .+$' |
| check_re = re.compile(regex, re.IGNORECASE) |
| |
| if options.args(): |
| raise ValueError(f'commit msg {field} check takes no options') |
| |
| filtered = _filter_diff( |
| diff, |
| [r'(^|/)(public_plus_experimental_current|current)\.txt$'] |
| ) |
| # If the commit does not contain a change to *current.txt, then this repo |
| # hook check no longer applies. |
| if not filtered: |
| return None |
| |
| found = [] |
| for line in desc.splitlines(): |
| if check_re.match(line): |
| found.append(line) |
| |
| if not found: |
| error = RELNOTE_REQUIRED_CURRENT_TXT_MSG % (regex) |
| else: |
| return None |
| |
| return [rh.results.HookResult(f'commit msg: "{field}:" check', |
| project, commit, error=error)] |
| |
| |
| def check_cpplint(project, commit, _desc, diff, options=None): |
| """Run cpplint.""" |
| # This list matches what cpplint expects. We could run on more (like .cxx), |
| # but cpplint would just ignore them. |
| filtered = _filter_diff(diff, [r'\.(cc|h|cpp|cu|cuh)$']) |
| if not filtered: |
| return None |
| |
| cpplint = options.tool_path('cpplint') |
| cmd = [cpplint] + options.args(('${PREUPLOAD_FILES}',), filtered) |
| return _check_cmd('cpplint', project, commit, cmd) |
| |
| |
| def check_gofmt(project, commit, _desc, diff, options=None): |
| """Checks that Go files are formatted with gofmt.""" |
| filtered = _filter_diff(diff, [r'\.go$']) |
| if not filtered: |
| return None |
| |
| gofmt = options.tool_path('gofmt') |
| cmd = [gofmt, '-l'] + options.args() |
| fixup_cmd = [gofmt, '-w'] + options.args() |
| |
| ret = [] |
| for d in filtered: |
| data = rh.git.get_file_content(commit, d.file) |
| result = _run(cmd, input=data) |
| if result.stdout: |
| ret.append(rh.results.HookResult( |
| 'gofmt', project, commit, error=result.stdout, |
| files=(d.file,), fixup_cmd=fixup_cmd)) |
| return ret |
| |
| |
| def check_json(project, commit, _desc, diff, options=None): |
| """Verify json files are valid.""" |
| if options.args(): |
| raise ValueError('json check takes no options') |
| |
| filtered = _filter_diff(diff, [r'\.json$']) |
| if not filtered: |
| return None |
| |
| ret = [] |
| for d in filtered: |
| data = rh.git.get_file_content(commit, d.file) |
| try: |
| json.loads(data) |
| except ValueError as e: |
| ret.append(rh.results.HookResult( |
| 'json', project, commit, error=str(e), |
| files=(d.file,))) |
| return ret |
| |
| |
| def _check_pylint(project, commit, _desc, diff, extra_args=None, options=None): |
| """Run pylint.""" |
| filtered = _filter_diff(diff, [r'\.py$']) |
| if not filtered: |
| return None |
| |
| if extra_args is None: |
| extra_args = [] |
| |
| pylint = options.tool_path('pylint') |
| cmd = [ |
| get_helper_path('pylint.py'), |
| '--executable-path', pylint, |
| ] + extra_args + options.args(('${PREUPLOAD_FILES}',), filtered) |
| return _check_cmd('pylint', project, commit, cmd) |
| |
| |
| def check_pylint2(project, commit, desc, diff, options=None): |
| """Run pylint through Python 2.""" |
| return _check_pylint(project, commit, desc, diff, options=options) |
| |
| |
| def check_pylint3(project, commit, desc, diff, options=None): |
| """Run pylint through Python 3.""" |
| return _check_pylint(project, commit, desc, diff, |
| extra_args=['--py3'], |
| options=options) |
| |
| |
| def check_rustfmt(project, commit, _desc, diff, options=None): |
| """Run "rustfmt --check" on diffed rust files""" |
| filtered = _filter_diff(diff, [r'\.rs$']) |
| if not filtered: |
| return None |
| |
| rustfmt = options.tool_path('rustfmt') |
| cmd = [rustfmt] + options.args((), filtered) |
| ret = [] |
| for d in filtered: |
| data = rh.git.get_file_content(commit, d.file) |
| result = _run(cmd, input=data) |
| # If the parsing failed, stdout will contain enough details on the |
| # location of the error. |
| if result.returncode: |
| ret.append(rh.results.HookResult( |
| 'rustfmt', project, commit, error=result.stdout, |
| files=(d.file,))) |
| continue |
| # TODO(b/164111102): rustfmt stable does not support --check on stdin. |
| # If no error is reported, compare stdin with stdout. |
| if data != result.stdout: |
| ret.append(rh.results.HookResult( |
| 'rustfmt', project, commit, error='Files not formatted', |
| files=(d.file,), fixup_cmd=cmd)) |
| return ret |
| |
| |
| def check_xmllint(project, commit, _desc, diff, options=None): |
| """Run xmllint.""" |
| # XXX: Should we drop most of these and probe for <?xml> tags? |
| extensions = frozenset(( |
| 'dbus-xml', # Generated DBUS interface. |
| 'dia', # File format for Dia. |
| 'dtd', # Document Type Definition. |
| 'fml', # Fuzzy markup language. |
| 'form', # Forms created by IntelliJ GUI Designer. |
| 'fxml', # JavaFX user interfaces. |
| 'glade', # Glade user interface design. |
| 'grd', # GRIT translation files. |
| 'iml', # Android build modules? |
| 'kml', # Keyhole Markup Language. |
| 'mxml', # Macromedia user interface markup language. |
| 'nib', # OS X Cocoa Interface Builder. |
| 'plist', # Property list (for OS X). |
| 'pom', # Project Object Model (for Apache Maven). |
| 'rng', # RELAX NG schemas. |
| 'sgml', # Standard Generalized Markup Language. |
| 'svg', # Scalable Vector Graphics. |
| 'uml', # Unified Modeling Language. |
| 'vcproj', # Microsoft Visual Studio project. |
| 'vcxproj', # Microsoft Visual Studio project. |
| 'wxs', # WiX Transform File. |
| 'xhtml', # XML HTML. |
| 'xib', # OS X Cocoa Interface Builder. |
| 'xlb', # Android locale bundle. |
| 'xml', # Extensible Markup Language. |
| 'xsd', # XML Schema Definition. |
| 'xsl', # Extensible Stylesheet Language. |
| )) |
| |
| filtered = _filter_diff(diff, [r'\.(' + '|'.join(extensions) + r')$']) |
| if not filtered: |
| return None |
| |
| # TODO: Figure out how to integrate schema validation. |
| # XXX: Should we use python's XML libs instead? |
| cmd = ['xmllint'] + options.args(('${PREUPLOAD_FILES}',), filtered) |
| |
| return _check_cmd('xmllint', project, commit, cmd) |
| |
| |
| def check_android_test_mapping(project, commit, _desc, diff, options=None): |
| """Verify Android TEST_MAPPING files are valid.""" |
| if options.args(): |
| raise ValueError('Android TEST_MAPPING check takes no options') |
| filtered = _filter_diff(diff, [r'(^|.*/)TEST_MAPPING$']) |
| if not filtered: |
| return None |
| |
| testmapping_format = options.tool_path('android-test-mapping-format') |
| testmapping_args = ['--commit', commit] |
| cmd = [testmapping_format] + options.args( |
| (project.dir, '${PREUPLOAD_FILES}'), filtered) + testmapping_args |
| return _check_cmd('android-test-mapping-format', project, commit, cmd) |
| |
| |
| def check_aidl_format(project, commit, _desc, diff, options=None): |
| """Checks that AIDL files are formatted with aidl-format.""" |
| # All *.aidl files except for those under aidl_api directory. |
| filtered = _filter_diff(diff, [r'\.aidl$'], [r'(^|/)aidl_api/']) |
| if not filtered: |
| return None |
| aidl_format = options.tool_path('aidl-format') |
| clang_format = options.tool_path('clang-format') |
| diff_cmd = [aidl_format, '-d', '--clang-format-path', clang_format] + \ |
| options.args((), filtered) |
| ret = [] |
| for d in filtered: |
| data = rh.git.get_file_content(commit, d.file) |
| result = _run(diff_cmd, input=data) |
| if result.stdout: |
| fixup_cmd = [aidl_format, '-w', '--clang-format-path', clang_format] |
| ret.append(rh.results.HookResult( |
| 'aidl-format', project, commit, error=result.stdout, |
| files=(d.file,), fixup_cmd=fixup_cmd)) |
| return ret |
| |
| |
| # Hooks that projects can opt into. |
| # Note: Make sure to keep the top level README.md up to date when adding more! |
| BUILTIN_HOOKS = { |
| 'aidl_format': check_aidl_format, |
| 'android_test_mapping_format': check_android_test_mapping, |
| 'aosp_license': check_aosp_license, |
| 'bpfmt': check_bpfmt, |
| 'checkpatch': check_checkpatch, |
| 'clang_format': check_clang_format, |
| 'commit_msg_bug_field': check_commit_msg_bug_field, |
| 'commit_msg_changeid_field': check_commit_msg_changeid_field, |
| 'commit_msg_prebuilt_apk_fields': check_commit_msg_prebuilt_apk_fields, |
| 'commit_msg_relnote_field_format': check_commit_msg_relnote_field_format, |
| 'commit_msg_relnote_for_current_txt': |
| check_commit_msg_relnote_for_current_txt, |
| 'commit_msg_test_field': check_commit_msg_test_field, |
| 'cpplint': check_cpplint, |
| 'gofmt': check_gofmt, |
| 'google_java_format': check_google_java_format, |
| 'jsonlint': check_json, |
| 'ktfmt': check_ktfmt, |
| 'pylint': check_pylint3, |
| 'pylint2': check_pylint2, |
| 'pylint3': check_pylint3, |
| 'rustfmt': check_rustfmt, |
| 'xmllint': check_xmllint, |
| } |
| |
| # Additional tools that the hooks can call with their default values. |
| # Note: Make sure to keep the top level README.md up to date when adding more! |
| TOOL_PATHS = { |
| 'aidl-format': 'aidl-format', |
| 'android-test-mapping-format': |
| os.path.join(TOOLS_DIR, 'android_test_mapping_format.py'), |
| 'bpfmt': 'bpfmt', |
| 'clang-format': 'clang-format', |
| 'cpplint': os.path.join(TOOLS_DIR, 'cpplint.py'), |
| 'git-clang-format': 'git-clang-format', |
| 'gofmt': 'gofmt', |
| 'google-java-format': 'google-java-format', |
| 'google-java-format-diff': 'google-java-format-diff.py', |
| 'ktfmt': 'ktfmt', |
| 'pylint': 'pylint', |
| 'rustfmt': 'rustfmt', |
| } |