• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# -*- coding:utf-8 -*-
2# Copyright 2016 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Functions that implement the actual checks."""
17
18from __future__ import print_function
19
20import json
21import os
22import platform
23import re
24import sys
25
26_path = os.path.realpath(__file__ + '/../..')
27if sys.path[0] != _path:
28    sys.path.insert(0, _path)
29del _path
30
31# pylint: disable=wrong-import-position
32import rh.results
33import rh.git
34import rh.utils
35
36
37class Placeholders(object):
38    """Holder class for replacing ${vars} in arg lists.
39
40    To add a new variable to replace in config files, just add it as a @property
41    to this class using the form.  So to add support for BIRD:
42      @property
43      def var_BIRD(self):
44        return <whatever this is>
45
46    You can return either a string or an iterable (e.g. a list or tuple).
47    """
48
49    def __init__(self, diff=()):
50        """Initialize.
51
52        Args:
53          diff: The list of files that changed.
54        """
55        self.diff = diff
56
57    def expand_vars(self, args):
58        """Perform place holder expansion on all of |args|.
59
60        Args:
61          args: The args to perform expansion on.
62
63        Returns:
64          The updated |args| list.
65        """
66        all_vars = set(self.vars())
67        replacements = dict((var, self.get(var)) for var in all_vars)
68
69        ret = []
70        for arg in args:
71            # First scan for exact matches
72            for key, val in replacements.items():
73                var = '${%s}' % (key,)
74                if arg == var:
75                    if isinstance(val, str):
76                        ret.append(val)
77                    else:
78                        ret.extend(val)
79                    # We break on first hit to avoid double expansion.
80                    break
81            else:
82                # If no exact matches, do an inline replacement.
83                def replace(m):
84                    val = self.get(m.group(1))
85                    if isinstance(val, str):
86                        return val
87                    else:
88                        return ' '.join(val)
89                ret.append(re.sub(r'\$\{(%s)\}' % ('|'.join(all_vars),),
90                                  replace, arg))
91
92        return ret
93
94    @classmethod
95    def vars(cls):
96        """Yield all replacement variable names."""
97        for key in dir(cls):
98            if key.startswith('var_'):
99                yield key[4:]
100
101    def get(self, var):
102        """Helper function to get the replacement |var| value."""
103        return getattr(self, 'var_%s' % (var,))
104
105    @property
106    def var_PREUPLOAD_COMMIT_MESSAGE(self):
107        """The git commit message."""
108        return os.environ.get('PREUPLOAD_COMMIT_MESSAGE', '')
109
110    @property
111    def var_PREUPLOAD_COMMIT(self):
112        """The git commit sha1."""
113        return os.environ.get('PREUPLOAD_COMMIT', '')
114
115    @property
116    def var_PREUPLOAD_FILES(self):
117        """List of files modified in this git commit."""
118        return [x.file for x in self.diff if x.status != 'D']
119
120    @property
121    def var_REPO_ROOT(self):
122        """The root of the repo checkout."""
123        return rh.git.find_repo_root()
124
125    @property
126    def var_BUILD_OS(self):
127        """The build OS (see _get_build_os_name for details)."""
128        return _get_build_os_name()
129
130
131class HookOptions(object):
132    """Holder class for hook options."""
133
134    def __init__(self, name, args, tool_paths):
135        """Initialize.
136
137        Args:
138          name: The name of the hook.
139          args: The override commandline arguments for the hook.
140          tool_paths: A dictionary with tool names to paths.
141        """
142        self.name = name
143        self._args = args
144        self._tool_paths = tool_paths
145
146    @staticmethod
147    def expand_vars(args, diff=()):
148        """Perform place holder expansion on all of |args|."""
149        replacer = Placeholders(diff=diff)
150        return replacer.expand_vars(args)
151
152    def args(self, default_args=(), diff=()):
153        """Gets the hook arguments, after performing place holder expansion.
154
155        Args:
156          default_args: The list to return if |self._args| is empty.
157          diff: The list of files that changed in the current commit.
158
159        Returns:
160          A list with arguments.
161        """
162        args = self._args
163        if not args:
164            args = default_args
165
166        return self.expand_vars(args, diff=diff)
167
168    def tool_path(self, tool_name):
169        """Gets the path in which the |tool_name| executable can be found.
170
171        This function performs expansion for some place holders.  If the tool
172        does not exist in the overridden |self._tool_paths| dictionary, the tool
173        name will be returned and will be run from the user's $PATH.
174
175        Args:
176          tool_name: The name of the executable.
177
178        Returns:
179          The path of the tool with all optional place holders expanded.
180        """
181        assert tool_name in TOOL_PATHS
182        if tool_name not in self._tool_paths:
183            return TOOL_PATHS[tool_name]
184
185        tool_path = os.path.normpath(self._tool_paths[tool_name])
186        return self.expand_vars([tool_path])[0]
187
188
189def _run_command(cmd, **kwargs):
190    """Helper command for checks that tend to gather output."""
191    kwargs.setdefault('redirect_stderr', True)
192    kwargs.setdefault('combine_stdout_stderr', True)
193    kwargs.setdefault('capture_output', True)
194    kwargs.setdefault('error_code_ok', True)
195    return rh.utils.run_command(cmd, **kwargs)
196
197
198def _match_regex_list(subject, expressions):
199    """Try to match a list of regular expressions to a string.
200
201    Args:
202      subject: The string to match regexes on.
203      expressions: An iterable of regular expressions to check for matches with.
204
205    Returns:
206      Whether the passed in subject matches any of the passed in regexes.
207    """
208    for expr in expressions:
209        if re.search(expr, subject):
210            return True
211    return False
212
213
214def _filter_diff(diff, include_list, exclude_list=()):
215    """Filter out files based on the conditions passed in.
216
217    Args:
218      diff: list of diff objects to filter.
219      include_list: list of regex that when matched with a file path will cause
220          it to be added to the output list unless the file is also matched with
221          a regex in the exclude_list.
222      exclude_list: list of regex that when matched with a file will prevent it
223          from being added to the output list, even if it is also matched with a
224          regex in the include_list.
225
226    Returns:
227      A list of filepaths that contain files matched in the include_list and not
228      in the exclude_list.
229    """
230    filtered = []
231    for d in diff:
232        if (d.status != 'D' and
233                _match_regex_list(d.file, include_list) and
234                not _match_regex_list(d.file, exclude_list)):
235            # We've got a match!
236            filtered.append(d)
237    return filtered
238
239
240def _get_build_os_name():
241    """Gets the build OS name.
242
243    Returns:
244      A string in a format usable to get prebuilt tool paths.
245    """
246    system = platform.system()
247    if 'Darwin' in system or 'Macintosh' in system:
248        return 'darwin-x86'
249    else:
250        # TODO: Add more values if needed.
251        return 'linux-x86'
252
253
254def _fixup_func_caller(cmd, **kwargs):
255    """Wraps |cmd| around a callable automated fixup.
256
257    For hooks that support automatically fixing errors after running (e.g. code
258    formatters), this function provides a way to run |cmd| as the |fixup_func|
259    parameter in HookCommandResult.
260    """
261    def wrapper():
262        result = _run_command(cmd, **kwargs)
263        if result.returncode not in (None, 0):
264            return result.output
265        return None
266    return wrapper
267
268
269def _check_cmd(hook_name, project, commit, cmd, fixup_func=None, **kwargs):
270    """Runs |cmd| and returns its result as a HookCommandResult."""
271    return [rh.results.HookCommandResult(hook_name, project, commit,
272                                         _run_command(cmd, **kwargs),
273                                         fixup_func=fixup_func)]
274
275
276# Where helper programs exist.
277TOOLS_DIR = os.path.realpath(__file__ + '/../../tools')
278
279def get_helper_path(tool):
280    """Return the full path to the helper |tool|."""
281    return os.path.join(TOOLS_DIR, tool)
282
283
284def check_custom(project, commit, _desc, diff, options=None, **kwargs):
285    """Run a custom hook."""
286    return _check_cmd(options.name, project, commit, options.args((), diff),
287                      **kwargs)
288
289
290def check_checkpatch(project, commit, _desc, diff, options=None):
291    """Run |diff| through the kernel's checkpatch.pl tool."""
292    tool = get_helper_path('checkpatch.pl')
293    cmd = ([tool, '-', '--root', project.dir] +
294           options.args(('--ignore=GERRIT_CHANGE_ID',), diff))
295    return _check_cmd('checkpatch.pl', project, commit, cmd,
296                      input=rh.git.get_patch(commit))
297
298
299def check_clang_format(project, commit, _desc, diff, options=None):
300    """Run git clang-format on the commit."""
301    tool = get_helper_path('clang-format.py')
302    clang_format = options.tool_path('clang-format')
303    git_clang_format = options.tool_path('git-clang-format')
304    tool_args = (['--clang-format', clang_format, '--git-clang-format',
305                  git_clang_format] +
306                 options.args(('--style', 'file', '--commit', commit), diff))
307    cmd = [tool] + tool_args
308    fixup_func = _fixup_func_caller([tool, '--fix'] + tool_args)
309    return _check_cmd('clang-format', project, commit, cmd,
310                      fixup_func=fixup_func)
311
312
313def check_google_java_format(project, commit, _desc, _diff, options=None):
314    """Run google-java-format on the commit."""
315
316    tool = get_helper_path('google-java-format.py')
317    google_java_format = options.tool_path('google-java-format')
318    google_java_format_diff = options.tool_path('google-java-format-diff')
319    tool_args = ['--google-java-format', google_java_format,
320                 '--google-java-format-diff', google_java_format_diff,
321                 '--commit', commit] + options.args()
322    cmd = [tool] + tool_args
323    fixup_func = _fixup_func_caller([tool, '--fix'] + tool_args)
324    return _check_cmd('google-java-format', project, commit, cmd,
325                      fixup_func=fixup_func)
326
327
328def check_commit_msg_bug_field(project, commit, desc, _diff, options=None):
329    """Check the commit message for a 'Bug:' line."""
330    field = 'Bug'
331    regex = r'^%s: (None|[0-9]+(, [0-9]+)*)$' % (field,)
332    check_re = re.compile(regex)
333
334    if options.args():
335        raise ValueError('commit msg %s check takes no options' % (field,))
336
337    found = []
338    for line in desc.splitlines():
339        if check_re.match(line):
340            found.append(line)
341
342    if not found:
343        error = ('Commit message is missing a "%s:" line.  It must match the\n'
344                 'following case-sensitive regex:\n\n    %s') % (field, regex)
345    else:
346        return
347
348    return [rh.results.HookResult('commit msg: "%s:" check' % (field,),
349                                  project, commit, error=error)]
350
351
352def check_commit_msg_changeid_field(project, commit, desc, _diff, options=None):
353    """Check the commit message for a 'Change-Id:' line."""
354    field = 'Change-Id'
355    regex = r'^%s: I[a-f0-9]+$' % (field,)
356    check_re = re.compile(regex)
357
358    if options.args():
359        raise ValueError('commit msg %s check takes no options' % (field,))
360
361    found = []
362    for line in desc.splitlines():
363        if check_re.match(line):
364            found.append(line)
365
366    if len(found) == 0:
367        error = ('Commit message is missing a "%s:" line.  It must match the\n'
368                 'following case-sensitive regex:\n\n    %s') % (field, regex)
369    elif len(found) > 1:
370        error = ('Commit message has too many "%s:" lines.  There can be only '
371                 'one.') % (field,)
372    else:
373        return
374
375    return [rh.results.HookResult('commit msg: "%s:" check' % (field,),
376                                  project, commit, error=error)]
377
378
379PREBUILT_APK_MSG = """Commit message is missing required prebuilt APK
380information.  To generate the information, use the aapt tool to dump badging
381information of the APKs being uploaded, specify where the APK was built, and
382specify whether the APKs are suitable for release:
383
384    for apk in $(find . -name '*.apk' | sort); do
385        echo "${apk}"
386        ${AAPT} dump badging "${apk}" |
387            grep -iE "(package: |sdkVersion:|targetSdkVersion:)" |
388            sed -e "s/' /'\\n/g"
389        echo
390    done
391
392It must match the following case-sensitive multiline regex searches:
393
394    %s
395
396For more information, see go/platform-prebuilt and go/android-prebuilt.
397
398"""
399
400
401def check_commit_msg_prebuilt_apk_fields(project, commit, desc, diff,
402                                         options=None):
403    """Check that prebuilt APK commits contain the required lines."""
404
405    if options.args():
406        raise ValueError('prebuilt apk check takes no options')
407
408    filtered = _filter_diff(diff, [r'\.apk$'])
409    if not filtered:
410        return
411
412    regexes = [
413        r'^package: .*$',
414        r'^sdkVersion:.*$',
415        r'^targetSdkVersion:.*$',
416        r'^Built here:.*$',
417        (r'^This build IS( NOT)? suitable for'
418         r'( preview|( preview or)? public) release'
419         r'( but IS NOT suitable for public release)?\.$')
420    ]
421
422    missing = []
423    for regex in regexes:
424        if not re.search(regex, desc, re.MULTILINE):
425            missing.append(regex)
426
427    if missing:
428        error = PREBUILT_APK_MSG % '\n    '.join(missing)
429    else:
430        return
431
432    return [rh.results.HookResult('commit msg: "prebuilt apk:" check',
433                                  project, commit, error=error)]
434
435
436TEST_MSG = """Commit message is missing a "Test:" line.  It must match the
437following case-sensitive regex:
438
439    %s
440
441The Test: stanza is free-form and should describe how you tested your change.
442As a CL author, you'll have a consistent place to describe the testing strategy
443you use for your work. As a CL reviewer, you'll be reminded to discuss testing
444as part of your code review, and you'll more easily replicate testing when you
445patch in CLs locally.
446
447Some examples below:
448
449Test: make WITH_TIDY=1 mmma art
450Test: make test-art
451Test: manual - took a photo
452Test: refactoring CL. Existing unit tests still pass.
453
454Check the git history for more examples. It's a free-form field, so we urge
455you to develop conventions that make sense for your project. Note that many
456projects use exact test commands, which are perfectly fine.
457
458Adding good automated tests with new code is critical to our goals of keeping
459the system stable and constantly improving quality. Please use Test: to
460highlight this area of your development. And reviewers, please insist on
461high-quality Test: descriptions.
462"""
463
464
465def check_commit_msg_test_field(project, commit, desc, _diff, options=None):
466    """Check the commit message for a 'Test:' line."""
467    field = 'Test'
468    regex = r'^%s: .*$' % (field,)
469    check_re = re.compile(regex)
470
471    if options.args():
472        raise ValueError('commit msg %s check takes no options' % (field,))
473
474    found = []
475    for line in desc.splitlines():
476        if check_re.match(line):
477            found.append(line)
478
479    if not found:
480        error = TEST_MSG % (regex)
481    else:
482        return
483
484    return [rh.results.HookResult('commit msg: "%s:" check' % (field,),
485                                  project, commit, error=error)]
486
487
488def check_cpplint(project, commit, _desc, diff, options=None):
489    """Run cpplint."""
490    # This list matches what cpplint expects.  We could run on more (like .cxx),
491    # but cpplint would just ignore them.
492    filtered = _filter_diff(diff, [r'\.(cc|h|cpp|cu|cuh)$'])
493    if not filtered:
494        return
495
496    cpplint = options.tool_path('cpplint')
497    cmd = [cpplint] + options.args(('${PREUPLOAD_FILES}',), filtered)
498    return _check_cmd('cpplint', project, commit, cmd)
499
500
501def check_gofmt(project, commit, _desc, diff, options=None):
502    """Checks that Go files are formatted with gofmt."""
503    filtered = _filter_diff(diff, [r'\.go$'])
504    if not filtered:
505        return
506
507    gofmt = options.tool_path('gofmt')
508    cmd = [gofmt, '-l'] + options.args((), filtered)
509    ret = []
510    for d in filtered:
511        data = rh.git.get_file_content(commit, d.file)
512        result = _run_command(cmd, input=data)
513        if result.output:
514            ret.append(rh.results.HookResult(
515                'gofmt', project, commit, error=result.output,
516                files=(d.file,)))
517    return ret
518
519
520def check_json(project, commit, _desc, diff, options=None):
521    """Verify json files are valid."""
522    if options.args():
523        raise ValueError('json check takes no options')
524
525    filtered = _filter_diff(diff, [r'\.json$'])
526    if not filtered:
527        return
528
529    ret = []
530    for d in filtered:
531        data = rh.git.get_file_content(commit, d.file)
532        try:
533            json.loads(data)
534        except ValueError as e:
535            ret.append(rh.results.HookResult(
536                'json', project, commit, error=str(e),
537                files=(d.file,)))
538    return ret
539
540
541def check_pylint(project, commit, _desc, diff, options=None):
542    """Run pylint."""
543    filtered = _filter_diff(diff, [r'\.py$'])
544    if not filtered:
545        return
546
547    pylint = options.tool_path('pylint')
548    cmd = [
549        get_helper_path('pylint.py'),
550        '--executable-path', pylint,
551    ] + options.args(('${PREUPLOAD_FILES}',), filtered)
552    return _check_cmd('pylint', project, commit, cmd)
553
554
555def check_xmllint(project, commit, _desc, diff, options=None):
556    """Run xmllint."""
557    # XXX: Should we drop most of these and probe for <?xml> tags?
558    extensions = frozenset((
559        'dbus-xml',  # Generated DBUS interface.
560        'dia',       # File format for Dia.
561        'dtd',       # Document Type Definition.
562        'fml',       # Fuzzy markup language.
563        'form',      # Forms created by IntelliJ GUI Designer.
564        'fxml',      # JavaFX user interfaces.
565        'glade',     # Glade user interface design.
566        'grd',       # GRIT translation files.
567        'iml',       # Android build modules?
568        'kml',       # Keyhole Markup Language.
569        'mxml',      # Macromedia user interface markup language.
570        'nib',       # OS X Cocoa Interface Builder.
571        'plist',     # Property list (for OS X).
572        'pom',       # Project Object Model (for Apache Maven).
573        'rng',       # RELAX NG schemas.
574        'sgml',      # Standard Generalized Markup Language.
575        'svg',       # Scalable Vector Graphics.
576        'uml',       # Unified Modeling Language.
577        'vcproj',    # Microsoft Visual Studio project.
578        'vcxproj',   # Microsoft Visual Studio project.
579        'wxs',       # WiX Transform File.
580        'xhtml',     # XML HTML.
581        'xib',       # OS X Cocoa Interface Builder.
582        'xlb',       # Android locale bundle.
583        'xml',       # Extensible Markup Language.
584        'xsd',       # XML Schema Definition.
585        'xsl',       # Extensible Stylesheet Language.
586    ))
587
588    filtered = _filter_diff(diff, [r'\.(%s)$' % '|'.join(extensions)])
589    if not filtered:
590        return
591
592    # TODO: Figure out how to integrate schema validation.
593    # XXX: Should we use python's XML libs instead?
594    cmd = ['xmllint'] + options.args(('${PREUPLOAD_FILES}',), filtered)
595
596    return _check_cmd('xmllint', project, commit, cmd)
597
598
599def check_android_test_mapping(project, commit, _desc, diff, options=None):
600    """Verify Android TEST_MAPPING files are valid."""
601    if options.args():
602        raise ValueError('Android TEST_MAPPING check takes no options')
603    filtered = _filter_diff(diff, [r'(^|.*/)TEST_MAPPING$'])
604    if not filtered:
605        return
606
607    testmapping_format = options.tool_path('android-test-mapping-format')
608    cmd = [testmapping_format] + options.args(
609        (project.dir, '${PREUPLOAD_FILES}',), filtered)
610    return _check_cmd('android-test-mapping-format', project, commit, cmd)
611
612
613# Hooks that projects can opt into.
614# Note: Make sure to keep the top level README.md up to date when adding more!
615BUILTIN_HOOKS = {
616    'android_test_mapping_format': check_android_test_mapping,
617    'checkpatch': check_checkpatch,
618    'clang_format': check_clang_format,
619    'commit_msg_bug_field': check_commit_msg_bug_field,
620    'commit_msg_changeid_field': check_commit_msg_changeid_field,
621    'commit_msg_prebuilt_apk_fields': check_commit_msg_prebuilt_apk_fields,
622    'commit_msg_test_field': check_commit_msg_test_field,
623    'cpplint': check_cpplint,
624    'gofmt': check_gofmt,
625    'google_java_format': check_google_java_format,
626    'jsonlint': check_json,
627    'pylint': check_pylint,
628    'xmllint': check_xmllint,
629}
630
631# Additional tools that the hooks can call with their default values.
632# Note: Make sure to keep the top level README.md up to date when adding more!
633TOOL_PATHS = {
634    'android-test-mapping-format':
635        os.path.join(TOOLS_DIR, 'android_test_mapping_format.py'),
636    'clang-format': 'clang-format',
637    'cpplint': os.path.join(TOOLS_DIR, 'cpplint.py'),
638    'git-clang-format': 'git-clang-format',
639    'gofmt': 'gofmt',
640    'google-java-format': 'google-java-format',
641    'google-java-format-diff': 'google-java-format-diff.py',
642    'pylint': 'pylint',
643}
644