1# Copyright 2016 The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Functions that implement the actual checks.""" 16 17import collections 18import fnmatch 19import json 20import os 21import platform 22import re 23import sys 24 25_path = os.path.realpath(__file__ + '/../..') 26if sys.path[0] != _path: 27 sys.path.insert(0, _path) 28del _path 29 30# pylint: disable=wrong-import-position 31import rh.git 32import rh.results 33import rh.utils 34 35 36class Placeholders(object): 37 """Holder class for replacing ${vars} in arg lists. 38 39 To add a new variable to replace in config files, just add it as a @property 40 to this class using the form. So to add support for BIRD: 41 @property 42 def var_BIRD(self): 43 return <whatever this is> 44 45 You can return either a string or an iterable (e.g. a list or tuple). 46 """ 47 48 def __init__(self, diff=()): 49 """Initialize. 50 51 Args: 52 diff: The list of files that changed. 53 """ 54 self.diff = diff 55 56 def expand_vars(self, args): 57 """Perform place holder expansion on all of |args|. 58 59 Args: 60 args: The args to perform expansion on. 61 62 Returns: 63 The updated |args| list. 64 """ 65 all_vars = set(self.vars()) 66 replacements = dict((var, self.get(var)) for var in all_vars) 67 68 ret = [] 69 for arg in args: 70 if arg.endswith('${PREUPLOAD_FILES_PREFIXED}'): 71 if arg == '${PREUPLOAD_FILES_PREFIXED}': 72 assert len(ret) > 1, ('PREUPLOAD_FILES_PREFIXED cannot be ' 73 'the 1st or 2nd argument') 74 prev_arg = ret[-1] 75 ret = ret[0:-1] 76 for file in self.get('PREUPLOAD_FILES'): 77 ret.append(prev_arg) 78 ret.append(file) 79 else: 80 prefix = arg[0:-len('${PREUPLOAD_FILES_PREFIXED}')] 81 ret.extend( 82 prefix + file for file in self.get('PREUPLOAD_FILES')) 83 else: 84 # First scan for exact matches 85 for key, val in replacements.items(): 86 var = '${%s}' % (key,) 87 if arg == var: 88 if isinstance(val, str): 89 ret.append(val) 90 else: 91 ret.extend(val) 92 # We break on first hit to avoid double expansion. 93 break 94 else: 95 # If no exact matches, do an inline replacement. 96 def replace(m): 97 val = self.get(m.group(1)) 98 if isinstance(val, str): 99 return val 100 return ' '.join(val) 101 ret.append(re.sub(r'\$\{(%s)\}' % ('|'.join(all_vars),), 102 replace, arg)) 103 return ret 104 105 @classmethod 106 def vars(cls): 107 """Yield all replacement variable names.""" 108 for key in dir(cls): 109 if key.startswith('var_'): 110 yield key[4:] 111 112 def get(self, var): 113 """Helper function to get the replacement |var| value.""" 114 return getattr(self, 'var_%s' % (var,)) 115 116 @property 117 def var_PREUPLOAD_COMMIT_MESSAGE(self): 118 """The git commit message.""" 119 return os.environ.get('PREUPLOAD_COMMIT_MESSAGE', '') 120 121 @property 122 def var_PREUPLOAD_COMMIT(self): 123 """The git commit sha1.""" 124 return os.environ.get('PREUPLOAD_COMMIT', '') 125 126 @property 127 def var_PREUPLOAD_FILES(self): 128 """List of files modified in this git commit.""" 129 return [x.file for x in self.diff if x.status != 'D'] 130 131 @property 132 def var_REPO_ROOT(self): 133 """The root of the repo checkout.""" 134 return rh.git.find_repo_root() 135 136 @property 137 def var_BUILD_OS(self): 138 """The build OS (see _get_build_os_name for details).""" 139 return _get_build_os_name() 140 141 142class ExclusionScope(object): 143 """Exclusion scope for a hook. 144 145 An exclusion scope can be used to determine if a hook has been disabled for 146 a specific project. 147 """ 148 149 def __init__(self, scope): 150 """Initialize. 151 152 Args: 153 scope: A list of shell-style wildcards (fnmatch) or regular 154 expression. Regular expressions must start with the ^ character. 155 """ 156 self._scope = [] 157 for path in scope: 158 if path.startswith('^'): 159 self._scope.append(re.compile(path)) 160 else: 161 self._scope.append(path) 162 163 def __contains__(self, proj_dir): 164 """Checks if |proj_dir| matches the excluded paths. 165 166 Args: 167 proj_dir: The relative path of the project. 168 """ 169 for exclusion_path in self._scope: 170 if hasattr(exclusion_path, 'match'): 171 if exclusion_path.match(proj_dir): 172 return True 173 elif fnmatch.fnmatch(proj_dir, exclusion_path): 174 return True 175 return False 176 177 178class HookOptions(object): 179 """Holder class for hook options.""" 180 181 def __init__(self, name, args, tool_paths): 182 """Initialize. 183 184 Args: 185 name: The name of the hook. 186 args: The override commandline arguments for the hook. 187 tool_paths: A dictionary with tool names to paths. 188 """ 189 self.name = name 190 self._args = args 191 self._tool_paths = tool_paths 192 193 @staticmethod 194 def expand_vars(args, diff=()): 195 """Perform place holder expansion on all of |args|.""" 196 replacer = Placeholders(diff=diff) 197 return replacer.expand_vars(args) 198 199 def args(self, default_args=(), diff=()): 200 """Gets the hook arguments, after performing place holder expansion. 201 202 Args: 203 default_args: The list to return if |self._args| is empty. 204 diff: The list of files that changed in the current commit. 205 206 Returns: 207 A list with arguments. 208 """ 209 args = self._args 210 if not args: 211 args = default_args 212 213 return self.expand_vars(args, diff=diff) 214 215 def tool_path(self, tool_name): 216 """Gets the path in which the |tool_name| executable can be found. 217 218 This function performs expansion for some place holders. If the tool 219 does not exist in the overridden |self._tool_paths| dictionary, the tool 220 name will be returned and will be run from the user's $PATH. 221 222 Args: 223 tool_name: The name of the executable. 224 225 Returns: 226 The path of the tool with all optional place holders expanded. 227 """ 228 assert tool_name in TOOL_PATHS 229 if tool_name not in self._tool_paths: 230 return TOOL_PATHS[tool_name] 231 232 tool_path = os.path.normpath(self._tool_paths[tool_name]) 233 return self.expand_vars([tool_path])[0] 234 235 236# A callable hook. 237CallableHook = collections.namedtuple('CallableHook', ('name', 'hook', 'scope')) 238 239 240def _run(cmd, **kwargs): 241 """Helper command for checks that tend to gather output.""" 242 kwargs.setdefault('combine_stdout_stderr', True) 243 kwargs.setdefault('capture_output', True) 244 kwargs.setdefault('check', False) 245 # Make sure hooks run with stdin disconnected to avoid accidentally 246 # interactive tools causing pauses. 247 kwargs.setdefault('input', '') 248 return rh.utils.run(cmd, **kwargs) 249 250 251def _match_regex_list(subject, expressions): 252 """Try to match a list of regular expressions to a string. 253 254 Args: 255 subject: The string to match regexes on. 256 expressions: An iterable of regular expressions to check for matches with. 257 258 Returns: 259 Whether the passed in subject matches any of the passed in regexes. 260 """ 261 for expr in expressions: 262 if re.search(expr, subject): 263 return True 264 return False 265 266 267def _filter_diff(diff, include_list, exclude_list=()): 268 """Filter out files based on the conditions passed in. 269 270 Args: 271 diff: list of diff objects to filter. 272 include_list: list of regex that when matched with a file path will cause 273 it to be added to the output list unless the file is also matched with 274 a regex in the exclude_list. 275 exclude_list: list of regex that when matched with a file will prevent it 276 from being added to the output list, even if it is also matched with a 277 regex in the include_list. 278 279 Returns: 280 A list of filepaths that contain files matched in the include_list and not 281 in the exclude_list. 282 """ 283 filtered = [] 284 for d in diff: 285 if (d.status != 'D' and 286 _match_regex_list(d.file, include_list) and 287 not _match_regex_list(d.file, exclude_list)): 288 # We've got a match! 289 filtered.append(d) 290 return filtered 291 292 293def _get_build_os_name(): 294 """Gets the build OS name. 295 296 Returns: 297 A string in a format usable to get prebuilt tool paths. 298 """ 299 system = platform.system() 300 if 'Darwin' in system or 'Macintosh' in system: 301 return 'darwin-x86' 302 303 # TODO: Add more values if needed. 304 return 'linux-x86' 305 306 307def _fixup_func_caller(cmd, **kwargs): 308 """Wraps |cmd| around a callable automated fixup. 309 310 For hooks that support automatically fixing errors after running (e.g. code 311 formatters), this function provides a way to run |cmd| as the |fixup_func| 312 parameter in HookCommandResult. 313 """ 314 def wrapper(): 315 result = _run(cmd, **kwargs) 316 if result.returncode not in (None, 0): 317 return result.stdout 318 return None 319 return wrapper 320 321 322def _check_cmd(hook_name, project, commit, cmd, fixup_func=None, **kwargs): 323 """Runs |cmd| and returns its result as a HookCommandResult.""" 324 return [rh.results.HookCommandResult(hook_name, project, commit, 325 _run(cmd, **kwargs), 326 fixup_func=fixup_func)] 327 328 329# Where helper programs exist. 330TOOLS_DIR = os.path.realpath(__file__ + '/../../tools') 331 332def get_helper_path(tool): 333 """Return the full path to the helper |tool|.""" 334 return os.path.join(TOOLS_DIR, tool) 335 336 337def check_custom(project, commit, _desc, diff, options=None, **kwargs): 338 """Run a custom hook.""" 339 return _check_cmd(options.name, project, commit, options.args((), diff), 340 **kwargs) 341 342 343def check_bpfmt(project, commit, _desc, diff, options=None): 344 """Checks that Blueprint files are formatted with bpfmt.""" 345 filtered = _filter_diff(diff, [r'\.bp$']) 346 if not filtered: 347 return None 348 349 bpfmt = options.tool_path('bpfmt') 350 cmd = [bpfmt, '-l'] + options.args((), filtered) 351 ret = [] 352 for d in filtered: 353 data = rh.git.get_file_content(commit, d.file) 354 result = _run(cmd, input=data) 355 if result.stdout: 356 ret.append(rh.results.HookResult( 357 'bpfmt', project, commit, error=result.stdout, 358 files=(d.file,))) 359 return ret 360 361 362def check_checkpatch(project, commit, _desc, diff, options=None): 363 """Run |diff| through the kernel's checkpatch.pl tool.""" 364 tool = get_helper_path('checkpatch.pl') 365 cmd = ([tool, '-', '--root', project.dir] + 366 options.args(('--ignore=GERRIT_CHANGE_ID',), diff)) 367 return _check_cmd('checkpatch.pl', project, commit, cmd, 368 input=rh.git.get_patch(commit)) 369 370 371def check_clang_format(project, commit, _desc, diff, options=None): 372 """Run git clang-format on the commit.""" 373 tool = get_helper_path('clang-format.py') 374 clang_format = options.tool_path('clang-format') 375 git_clang_format = options.tool_path('git-clang-format') 376 tool_args = (['--clang-format', clang_format, '--git-clang-format', 377 git_clang_format] + 378 options.args(('--style', 'file', '--commit', commit), diff)) 379 cmd = [tool] + tool_args 380 fixup_func = _fixup_func_caller([tool, '--fix'] + tool_args) 381 return _check_cmd('clang-format', project, commit, cmd, 382 fixup_func=fixup_func) 383 384 385def check_google_java_format(project, commit, _desc, _diff, options=None): 386 """Run google-java-format on the commit.""" 387 388 tool = get_helper_path('google-java-format.py') 389 google_java_format = options.tool_path('google-java-format') 390 google_java_format_diff = options.tool_path('google-java-format-diff') 391 tool_args = ['--google-java-format', google_java_format, 392 '--google-java-format-diff', google_java_format_diff, 393 '--commit', commit] + options.args() 394 cmd = [tool] + tool_args 395 fixup_func = _fixup_func_caller([tool, '--fix'] + tool_args) 396 return _check_cmd('google-java-format', project, commit, cmd, 397 fixup_func=fixup_func) 398 399 400def check_commit_msg_bug_field(project, commit, desc, _diff, options=None): 401 """Check the commit message for a 'Bug:' line.""" 402 field = 'Bug' 403 regex = r'^%s: (None|[0-9]+(, [0-9]+)*)$' % (field,) 404 check_re = re.compile(regex) 405 406 if options.args(): 407 raise ValueError('commit msg %s check takes no options' % (field,)) 408 409 found = [] 410 for line in desc.splitlines(): 411 if check_re.match(line): 412 found.append(line) 413 414 if not found: 415 error = ('Commit message is missing a "%s:" line. It must match the\n' 416 'following case-sensitive regex:\n\n %s') % (field, regex) 417 else: 418 return None 419 420 return [rh.results.HookResult('commit msg: "%s:" check' % (field,), 421 project, commit, error=error)] 422 423 424def check_commit_msg_changeid_field(project, commit, desc, _diff, options=None): 425 """Check the commit message for a 'Change-Id:' line.""" 426 field = 'Change-Id' 427 regex = r'^%s: I[a-f0-9]+$' % (field,) 428 check_re = re.compile(regex) 429 430 if options.args(): 431 raise ValueError('commit msg %s check takes no options' % (field,)) 432 433 found = [] 434 for line in desc.splitlines(): 435 if check_re.match(line): 436 found.append(line) 437 438 if not found: 439 error = ('Commit message is missing a "%s:" line. It must match the\n' 440 'following case-sensitive regex:\n\n %s') % (field, regex) 441 elif len(found) > 1: 442 error = ('Commit message has too many "%s:" lines. There can be only ' 443 'one.') % (field,) 444 else: 445 return None 446 447 return [rh.results.HookResult('commit msg: "%s:" check' % (field,), 448 project, commit, error=error)] 449 450 451PREBUILT_APK_MSG = """Commit message is missing required prebuilt APK 452information. To generate the information, use the aapt tool to dump badging 453information of the APKs being uploaded, specify where the APK was built, and 454specify whether the APKs are suitable for release: 455 456 for apk in $(find . -name '*.apk' | sort); do 457 echo "${apk}" 458 ${AAPT} dump badging "${apk}" | 459 grep -iE "(package: |sdkVersion:|targetSdkVersion:)" | 460 sed -e "s/' /'\\n/g" 461 echo 462 done 463 464It must match the following case-sensitive multiline regex searches: 465 466 %s 467 468For more information, see go/platform-prebuilt and go/android-prebuilt. 469 470""" 471 472 473def check_commit_msg_prebuilt_apk_fields(project, commit, desc, diff, 474 options=None): 475 """Check that prebuilt APK commits contain the required lines.""" 476 477 if options.args(): 478 raise ValueError('prebuilt apk check takes no options') 479 480 filtered = _filter_diff(diff, [r'\.apk$']) 481 if not filtered: 482 return None 483 484 regexes = [ 485 r'^package: .*$', 486 r'^sdkVersion:.*$', 487 r'^targetSdkVersion:.*$', 488 r'^Built here:.*$', 489 (r'^This build IS( NOT)? suitable for' 490 r'( preview|( preview or)? public) release' 491 r'( but IS NOT suitable for public release)?\.$') 492 ] 493 494 missing = [] 495 for regex in regexes: 496 if not re.search(regex, desc, re.MULTILINE): 497 missing.append(regex) 498 499 if missing: 500 error = PREBUILT_APK_MSG % '\n '.join(missing) 501 else: 502 return None 503 504 return [rh.results.HookResult('commit msg: "prebuilt apk:" check', 505 project, commit, error=error)] 506 507 508TEST_MSG = """Commit message is missing a "Test:" line. It must match the 509following case-sensitive regex: 510 511 %s 512 513The Test: stanza is free-form and should describe how you tested your change. 514As a CL author, you'll have a consistent place to describe the testing strategy 515you use for your work. As a CL reviewer, you'll be reminded to discuss testing 516as part of your code review, and you'll more easily replicate testing when you 517patch in CLs locally. 518 519Some examples below: 520 521Test: make WITH_TIDY=1 mmma art 522Test: make test-art 523Test: manual - took a photo 524Test: refactoring CL. Existing unit tests still pass. 525 526Check the git history for more examples. It's a free-form field, so we urge 527you to develop conventions that make sense for your project. Note that many 528projects use exact test commands, which are perfectly fine. 529 530Adding good automated tests with new code is critical to our goals of keeping 531the system stable and constantly improving quality. Please use Test: to 532highlight this area of your development. And reviewers, please insist on 533high-quality Test: descriptions. 534""" 535 536 537def check_commit_msg_test_field(project, commit, desc, _diff, options=None): 538 """Check the commit message for a 'Test:' line.""" 539 field = 'Test' 540 regex = r'^%s: .*$' % (field,) 541 check_re = re.compile(regex) 542 543 if options.args(): 544 raise ValueError('commit msg %s check takes no options' % (field,)) 545 546 found = [] 547 for line in desc.splitlines(): 548 if check_re.match(line): 549 found.append(line) 550 551 if not found: 552 error = TEST_MSG % (regex) 553 else: 554 return None 555 556 return [rh.results.HookResult('commit msg: "%s:" check' % (field,), 557 project, commit, error=error)] 558 559 560RELNOTE_MISSPELL_MSG = """Commit message contains something that looks 561similar to the "Relnote:" tag. It must match the regex: 562 563 %s 564 565The Relnote: stanza is free-form and should describe what developers need to 566know about your change. 567 568Some examples below: 569 570Relnote: "Added a new API `Class#isBetter` to determine whether or not the 571class is better" 572Relnote: Fixed an issue where the UI would hang on a double tap. 573 574Check the git history for more examples. It's a free-form field, so we urge 575you to develop conventions that make sense for your project. 576""" 577 578RELNOTE_MISSING_QUOTES_MSG = """Commit message contains something that looks 579similar to the "Relnote:" tag but might be malformatted. For multiline 580release notes, you need to include a starting and closing quote. 581 582Multi-line Relnote example: 583 584Relnote: "Added a new API `Class#getSize` to get the size of the class. 585This is useful if you need to know the size of the class." 586 587Single-line Relnote example: 588 589Relnote: Added a new API `Class#containsData` 590""" 591 592RELNOTE_INVALID_QUOTES_MSG = """Commit message contains something that looks 593similar to the "Relnote:" tag but might be malformatted. If you are using 594quotes that do not mark the start or end of a Relnote, you need to escape them 595with a backslash. 596 597Non-starting/non-ending quote Relnote examples: 598 599Relnote: "Fixed an error with `Class#getBar()` where \"foo\" would be returned 600in edge cases." 601Relnote: Added a new API to handle strings like \"foo\" 602""" 603 604def check_commit_msg_relnote_field_format(project, commit, desc, _diff, 605 options=None): 606 """Check the commit for one correctly formatted 'Relnote:' line. 607 608 Checks the commit message for two things: 609 (1) Checks for possible misspellings of the 'Relnote:' tag. 610 (2) Ensures that multiline release notes are properly formatted with a 611 starting quote and an endling quote. 612 (3) Checks that release notes that contain non-starting or non-ending 613 quotes are escaped with a backslash. 614 """ 615 field = 'Relnote' 616 regex_relnote = r'^%s:.*$' % (field,) 617 check_re_relnote = re.compile(regex_relnote, re.IGNORECASE) 618 619 if options.args(): 620 raise ValueError('commit msg %s check takes no options' % (field,)) 621 622 # Check 1: Check for possible misspellings of the `Relnote:` field. 623 624 # Regex for misspelled fields. 625 possible_field_misspells = {'Relnotes', 'ReleaseNote', 626 'Rel-note', 'Rel note', 627 'rel-notes', 'releasenotes', 628 'release-note', 'release-notes'} 629 regex_field_misspells = r'^(%s): .*$' % ( 630 '|'.join(possible_field_misspells), 631 ) 632 check_re_field_misspells = re.compile(regex_field_misspells, re.IGNORECASE) 633 634 ret = [] 635 for line in desc.splitlines(): 636 if check_re_field_misspells.match(line): 637 error = RELNOTE_MISSPELL_MSG % (regex_relnote, ) 638 ret.append( 639 rh.results.HookResult(('commit msg: "%s:" ' 640 'tag spelling error') % (field,), 641 project, commit, error=error)) 642 643 # Check 2: Check that multiline Relnotes are quoted. 644 645 check_re_empty_string = re.compile(r'^$') 646 647 # Regex to find other fields that could be used. 648 regex_other_fields = r'^[a-zA-Z0-9-]+:' 649 check_re_other_fields = re.compile(regex_other_fields) 650 651 desc_lines = desc.splitlines() 652 for i, cur_line in enumerate(desc_lines): 653 # Look for a Relnote tag that is before the last line and 654 # lacking any quotes. 655 if (check_re_relnote.match(cur_line) and 656 i < len(desc_lines) - 1 and 657 '"' not in cur_line): 658 next_line = desc_lines[i + 1] 659 # Check that the next line does not contain any other field 660 # and it's not an empty string. 661 if (not check_re_other_fields.findall(next_line) and 662 not check_re_empty_string.match(next_line)): 663 ret.append( 664 rh.results.HookResult(('commit msg: "%s:" ' 665 'tag missing quotes') % (field,), 666 project, commit, 667 error=RELNOTE_MISSING_QUOTES_MSG)) 668 break 669 670 # Check 3: Check that multiline Relnotes contain matching quotes. 671 first_quote_found = False 672 second_quote_found = False 673 for cur_line in desc_lines: 674 contains_quote = '"' in cur_line 675 contains_field = check_re_other_fields.findall(cur_line) 676 # If we have found the first quote and another field, break and fail. 677 if first_quote_found and contains_field: 678 break 679 # If we have found the first quote, this line contains a quote, 680 # and this line is not another field, break and succeed. 681 if first_quote_found and contains_quote: 682 second_quote_found = True 683 break 684 # Check that the `Relnote:` tag exists and it contains a starting quote. 685 if check_re_relnote.match(cur_line) and contains_quote: 686 first_quote_found = True 687 # A single-line Relnote containing a start and ending triple quote 688 # is valid. 689 if cur_line.count('"""') == 2: 690 second_quote_found = True 691 break 692 # A single-line Relnote containing a start and ending quote 693 # is valid. 694 if cur_line.count('"') - cur_line.count('\\"') == 2: 695 second_quote_found = True 696 break 697 if first_quote_found != second_quote_found: 698 ret.append( 699 rh.results.HookResult(('commit msg: "%s:" ' 700 'tag missing closing quote') % (field,), 701 project, commit, 702 error=RELNOTE_MISSING_QUOTES_MSG)) 703 704 # Check 4: Check that non-starting or non-ending quotes are escaped with a 705 # backslash. 706 line_needs_checking = False 707 uses_invalid_quotes = False 708 for cur_line in desc_lines: 709 if check_re_other_fields.findall(cur_line): 710 line_needs_checking = False 711 on_relnote_line = check_re_relnote.match(cur_line) 712 # Determine if we are parsing the base `Relnote:` line. 713 if on_relnote_line and '"' in cur_line: 714 line_needs_checking = True 715 # We don't think anyone will type '"""' and then forget to 716 # escape it, so we're not checking for this. 717 if '"""' in cur_line: 718 break 719 if line_needs_checking: 720 stripped_line = re.sub('^%s:' % field, '', cur_line, 721 flags=re.IGNORECASE).strip() 722 for i, character in enumerate(stripped_line): 723 if i == 0: 724 # Case 1: Valid quote at the beginning of the 725 # base `Relnote:` line. 726 if on_relnote_line: 727 continue 728 # Case 2: Invalid quote at the beginning of following 729 # lines, where we are not terminating the release note. 730 if character == '"' and stripped_line != '"': 731 uses_invalid_quotes = True 732 break 733 # Case 3: Check all other cases. 734 if (character == '"' 735 and 0 < i < len(stripped_line) - 1 736 and stripped_line[i-1] != '"' 737 and stripped_line[i-1] != "\\"): 738 uses_invalid_quotes = True 739 break 740 741 if uses_invalid_quotes: 742 ret.append(rh.results.HookResult(('commit msg: "%s:" ' 743 'tag using unescaped ' 744 'quotes') % (field,), 745 project, commit, 746 error=RELNOTE_INVALID_QUOTES_MSG)) 747 return ret 748 749 750RELNOTE_REQUIRED_CURRENT_TXT_MSG = """\ 751Commit contains a change to current.txt or public_plus_experimental_current.txt, 752but the commit message does not contain the required `Relnote:` tag. It must 753match the regex: 754 755 %s 756 757The Relnote: stanza is free-form and should describe what developers need to 758know about your change. If you are making infrastructure changes, you 759can set the Relnote: stanza to be "N/A" for the commit to not be included 760in release notes. 761 762Some examples: 763 764Relnote: "Added a new API `Class#isBetter` to determine whether or not the 765class is better" 766Relnote: Fixed an issue where the UI would hang on a double tap. 767Relnote: N/A 768 769Check the git history for more examples. 770""" 771 772def check_commit_msg_relnote_for_current_txt(project, commit, desc, diff, 773 options=None): 774 """Check changes to current.txt contain the 'Relnote:' stanza.""" 775 field = 'Relnote' 776 regex = r'^%s: .+$' % (field,) 777 check_re = re.compile(regex, re.IGNORECASE) 778 779 if options.args(): 780 raise ValueError('commit msg %s check takes no options' % (field,)) 781 782 filtered = _filter_diff( 783 diff, 784 [r'(^|/)(public_plus_experimental_current|current)\.txt$'] 785 ) 786 # If the commit does not contain a change to *current.txt, then this repo 787 # hook check no longer applies. 788 if not filtered: 789 return None 790 791 found = [] 792 for line in desc.splitlines(): 793 if check_re.match(line): 794 found.append(line) 795 796 if not found: 797 error = RELNOTE_REQUIRED_CURRENT_TXT_MSG % (regex) 798 else: 799 return None 800 801 return [rh.results.HookResult('commit msg: "%s:" check' % (field,), 802 project, commit, error=error)] 803 804 805def check_cpplint(project, commit, _desc, diff, options=None): 806 """Run cpplint.""" 807 # This list matches what cpplint expects. We could run on more (like .cxx), 808 # but cpplint would just ignore them. 809 filtered = _filter_diff(diff, [r'\.(cc|h|cpp|cu|cuh)$']) 810 if not filtered: 811 return None 812 813 cpplint = options.tool_path('cpplint') 814 cmd = [cpplint] + options.args(('${PREUPLOAD_FILES}',), filtered) 815 return _check_cmd('cpplint', project, commit, cmd) 816 817 818def check_gofmt(project, commit, _desc, diff, options=None): 819 """Checks that Go files are formatted with gofmt.""" 820 filtered = _filter_diff(diff, [r'\.go$']) 821 if not filtered: 822 return None 823 824 gofmt = options.tool_path('gofmt') 825 cmd = [gofmt, '-l'] + options.args((), filtered) 826 ret = [] 827 for d in filtered: 828 data = rh.git.get_file_content(commit, d.file) 829 result = _run(cmd, input=data) 830 if result.stdout: 831 fixup_func = _fixup_func_caller([gofmt, '-w', d.file]) 832 ret.append(rh.results.HookResult( 833 'gofmt', project, commit, error=result.stdout, 834 files=(d.file,), fixup_func=fixup_func)) 835 return ret 836 837 838def check_json(project, commit, _desc, diff, options=None): 839 """Verify json files are valid.""" 840 if options.args(): 841 raise ValueError('json check takes no options') 842 843 filtered = _filter_diff(diff, [r'\.json$']) 844 if not filtered: 845 return None 846 847 ret = [] 848 for d in filtered: 849 data = rh.git.get_file_content(commit, d.file) 850 try: 851 json.loads(data) 852 except ValueError as e: 853 ret.append(rh.results.HookResult( 854 'json', project, commit, error=str(e), 855 files=(d.file,))) 856 return ret 857 858 859def _check_pylint(project, commit, _desc, diff, extra_args=None, options=None): 860 """Run pylint.""" 861 filtered = _filter_diff(diff, [r'\.py$']) 862 if not filtered: 863 return None 864 865 if extra_args is None: 866 extra_args = [] 867 868 pylint = options.tool_path('pylint') 869 cmd = [ 870 get_helper_path('pylint.py'), 871 '--executable-path', pylint, 872 ] + extra_args + options.args(('${PREUPLOAD_FILES}',), filtered) 873 return _check_cmd('pylint', project, commit, cmd) 874 875 876def check_pylint2(project, commit, desc, diff, options=None): 877 """Run pylint through Python 2.""" 878 return _check_pylint(project, commit, desc, diff, options=options) 879 880 881def check_pylint3(project, commit, desc, diff, options=None): 882 """Run pylint through Python 3.""" 883 return _check_pylint(project, commit, desc, diff, 884 extra_args=['--py3'], 885 options=options) 886 887 888def check_rustfmt(project, commit, _desc, diff, options=None): 889 """Run "rustfmt --check" on diffed rust files""" 890 filtered = _filter_diff(diff, [r'\.rs$']) 891 if not filtered: 892 return None 893 894 rustfmt = options.tool_path('rustfmt') 895 cmd = [rustfmt] + options.args((), filtered) 896 ret = [] 897 for d in filtered: 898 data = rh.git.get_file_content(commit, d.file) 899 result = _run(cmd, input=data) 900 # If the parsing failed, stdout will contain enough details on the 901 # location of the error. 902 if result.returncode: 903 ret.append(rh.results.HookResult( 904 'rustfmt', project, commit, error=result.stdout, 905 files=(d.file,))) 906 continue 907 # TODO(b/164111102): rustfmt stable does not support --check on stdin. 908 # If no error is reported, compare stdin with stdout. 909 if data != result.stdout: 910 msg = ('To fix, please run: %s' % 911 rh.shell.cmd_to_str(cmd + [d.file])) 912 ret.append(rh.results.HookResult( 913 'rustfmt', project, commit, error=msg, 914 files=(d.file,))) 915 return ret 916 917 918def check_xmllint(project, commit, _desc, diff, options=None): 919 """Run xmllint.""" 920 # XXX: Should we drop most of these and probe for <?xml> tags? 921 extensions = frozenset(( 922 'dbus-xml', # Generated DBUS interface. 923 'dia', # File format for Dia. 924 'dtd', # Document Type Definition. 925 'fml', # Fuzzy markup language. 926 'form', # Forms created by IntelliJ GUI Designer. 927 'fxml', # JavaFX user interfaces. 928 'glade', # Glade user interface design. 929 'grd', # GRIT translation files. 930 'iml', # Android build modules? 931 'kml', # Keyhole Markup Language. 932 'mxml', # Macromedia user interface markup language. 933 'nib', # OS X Cocoa Interface Builder. 934 'plist', # Property list (for OS X). 935 'pom', # Project Object Model (for Apache Maven). 936 'rng', # RELAX NG schemas. 937 'sgml', # Standard Generalized Markup Language. 938 'svg', # Scalable Vector Graphics. 939 'uml', # Unified Modeling Language. 940 'vcproj', # Microsoft Visual Studio project. 941 'vcxproj', # Microsoft Visual Studio project. 942 'wxs', # WiX Transform File. 943 'xhtml', # XML HTML. 944 'xib', # OS X Cocoa Interface Builder. 945 'xlb', # Android locale bundle. 946 'xml', # Extensible Markup Language. 947 'xsd', # XML Schema Definition. 948 'xsl', # Extensible Stylesheet Language. 949 )) 950 951 filtered = _filter_diff(diff, [r'\.(%s)$' % '|'.join(extensions)]) 952 if not filtered: 953 return None 954 955 # TODO: Figure out how to integrate schema validation. 956 # XXX: Should we use python's XML libs instead? 957 cmd = ['xmllint'] + options.args(('${PREUPLOAD_FILES}',), filtered) 958 959 return _check_cmd('xmllint', project, commit, cmd) 960 961 962def check_android_test_mapping(project, commit, _desc, diff, options=None): 963 """Verify Android TEST_MAPPING files are valid.""" 964 if options.args(): 965 raise ValueError('Android TEST_MAPPING check takes no options') 966 filtered = _filter_diff(diff, [r'(^|.*/)TEST_MAPPING$']) 967 if not filtered: 968 return None 969 970 testmapping_format = options.tool_path('android-test-mapping-format') 971 testmapping_args = ['--commit', commit] 972 cmd = [testmapping_format] + options.args( 973 (project.dir, '${PREUPLOAD_FILES}'), filtered) + testmapping_args 974 return _check_cmd('android-test-mapping-format', project, commit, cmd) 975 976 977def check_aidl_format(project, commit, _desc, diff, options=None): 978 """Checks that AIDL files are formatted with aidl-format.""" 979 # All *.aidl files except for those under aidl_api directory. 980 filtered = _filter_diff(diff, [r'\.aidl$'], [r'/aidl_api/']) 981 if not filtered: 982 return None 983 aidl_format = options.tool_path('aidl-format') 984 cmd = [aidl_format, '-d'] + options.args((), filtered) 985 ret = [] 986 for d in filtered: 987 data = rh.git.get_file_content(commit, d.file) 988 result = _run(cmd, input=data) 989 if result.stdout: 990 fixup_func = _fixup_func_caller([aidl_format, '-w', d.file]) 991 ret.append(rh.results.HookResult( 992 'aidl-format', project, commit, error=result.stdout, 993 files=(d.file,), fixup_func=fixup_func)) 994 return ret 995 996 997# Hooks that projects can opt into. 998# Note: Make sure to keep the top level README.md up to date when adding more! 999BUILTIN_HOOKS = { 1000 'aidl_format': check_aidl_format, 1001 'android_test_mapping_format': check_android_test_mapping, 1002 'bpfmt': check_bpfmt, 1003 'checkpatch': check_checkpatch, 1004 'clang_format': check_clang_format, 1005 'commit_msg_bug_field': check_commit_msg_bug_field, 1006 'commit_msg_changeid_field': check_commit_msg_changeid_field, 1007 'commit_msg_prebuilt_apk_fields': check_commit_msg_prebuilt_apk_fields, 1008 'commit_msg_test_field': check_commit_msg_test_field, 1009 'commit_msg_relnote_field_format': check_commit_msg_relnote_field_format, 1010 'commit_msg_relnote_for_current_txt': 1011 check_commit_msg_relnote_for_current_txt, 1012 'cpplint': check_cpplint, 1013 'gofmt': check_gofmt, 1014 'google_java_format': check_google_java_format, 1015 'jsonlint': check_json, 1016 'pylint': check_pylint2, 1017 'pylint2': check_pylint2, 1018 'pylint3': check_pylint3, 1019 'rustfmt': check_rustfmt, 1020 'xmllint': check_xmllint, 1021} 1022 1023# Additional tools that the hooks can call with their default values. 1024# Note: Make sure to keep the top level README.md up to date when adding more! 1025TOOL_PATHS = { 1026 'aidl-format': 'aidl-format', 1027 'android-test-mapping-format': 1028 os.path.join(TOOLS_DIR, 'android_test_mapping_format.py'), 1029 'bpfmt': 'bpfmt', 1030 'clang-format': 'clang-format', 1031 'cpplint': os.path.join(TOOLS_DIR, 'cpplint.py'), 1032 'git-clang-format': 'git-clang-format', 1033 'gofmt': 'gofmt', 1034 'google-java-format': 'google-java-format', 1035 'google-java-format-diff': 'google-java-format-diff.py', 1036 'pylint': 'pylint', 1037 'rustfmt': 'rustfmt', 1038} 1039