Home | History | Annotate | Download | only in skia
      1 # Copyright (c) 2013 The Chromium Authors. All rights reserved.
      2 # Use of this source code is governed by a BSD-style license that can be
      3 # found in the LICENSE file.
      4 
      5 
      6 """Top-level presubmit script for Skia.
      7 
      8 See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
      9 for more details about the presubmit API built into gcl.
     10 """
     11 
     12 import csv
     13 import fnmatch
     14 import os
     15 import re
     16 import subprocess
     17 import sys
     18 import traceback
     19 
     20 
     21 REVERT_CL_SUBJECT_PREFIX = 'Revert '
     22 
     23 SKIA_TREE_STATUS_URL = 'http://skia-tree-status.appspot.com'
     24 
     25 CQ_KEYWORDS_THAT_NEED_APPENDING = ('CQ_INCLUDE_TRYBOTS', 'CQ_EXTRA_TRYBOTS',
     26                                    'CQ_EXCLUDE_TRYBOTS', 'CQ_TRYBOTS')
     27 
     28 # Please add the complete email address here (and not just 'xyz@' or 'xyz').
     29 PUBLIC_API_OWNERS = (
     30     'reed (at] chromium.org',
     31     'reed (at] google.com',
     32     'bsalomon (at] chromium.org',
     33     'bsalomon (at] google.com',
     34     'djsollen (at] chromium.org',
     35     'djsollen (at] google.com',
     36 )
     37 
     38 AUTHORS_FILE_NAME = 'AUTHORS'
     39 
     40 DOCS_PREVIEW_URL = 'https://skia.org/?cl='
     41 
     42 
     43 def _CheckChangeHasEol(input_api, output_api, source_file_filter=None):
     44   """Checks that files end with atleast one \n (LF)."""
     45   eof_files = []
     46   for f in input_api.AffectedSourceFiles(source_file_filter):
     47     contents = input_api.ReadFile(f, 'rb')
     48     # Check that the file ends in atleast one newline character.
     49     if len(contents) > 1 and contents[-1:] != '\n':
     50       eof_files.append(f.LocalPath())
     51 
     52   if eof_files:
     53     return [output_api.PresubmitPromptWarning(
     54       'These files should end in a newline character:',
     55       items=eof_files)]
     56   return []
     57 
     58 
     59 def _PythonChecks(input_api, output_api):
     60   """Run checks on any modified Python files."""
     61   pylint_disabled_warnings = (
     62       'F0401',  # Unable to import.
     63       'E0611',  # No name in module.
     64       'W0232',  # Class has no __init__ method.
     65       'E1002',  # Use of super on an old style class.
     66       'W0403',  # Relative import used.
     67       'R0201',  # Method could be a function.
     68       'E1003',  # Using class name in super.
     69       'W0613',  # Unused argument.
     70   )
     71   # Run Pylint on only the modified python files. Unfortunately it still runs
     72   # Pylint on the whole file instead of just the modified lines.
     73   affected_python_files = []
     74   for affected_file in input_api.AffectedSourceFiles(None):
     75     affected_file_path = affected_file.LocalPath()
     76     if affected_file_path.endswith('.py'):
     77       affected_python_files.append(affected_file_path)
     78   return input_api.canned_checks.RunPylint(
     79       input_api, output_api,
     80       disabled_warnings=pylint_disabled_warnings,
     81       white_list=affected_python_files)
     82 
     83 
     84 def _IfDefChecks(input_api, output_api):
     85   """Ensures if/ifdef are not before includes. See skbug/3362 for details."""
     86   comment_block_start_pattern = re.compile('^\s*\/\*.*$')
     87   comment_block_middle_pattern = re.compile('^\s+\*.*')
     88   comment_block_end_pattern = re.compile('^\s+\*\/.*$')
     89   single_line_comment_pattern = re.compile('^\s*//.*$')
     90   def is_comment(line):
     91     return (comment_block_start_pattern.match(line) or
     92             comment_block_middle_pattern.match(line) or
     93             comment_block_end_pattern.match(line) or
     94             single_line_comment_pattern.match(line))
     95 
     96   empty_line_pattern = re.compile('^\s*$')
     97   def is_empty_line(line):
     98     return empty_line_pattern.match(line)
     99 
    100   failing_files = []
    101   for affected_file in input_api.AffectedSourceFiles(None):
    102     affected_file_path = affected_file.LocalPath()
    103     if affected_file_path.endswith('.cpp') or affected_file_path.endswith('.h'):
    104       f = open(affected_file_path)
    105       for line in f.xreadlines():
    106         if is_comment(line) or is_empty_line(line):
    107           continue
    108         # The below will be the first real line after comments and newlines.
    109         if line.startswith('#if 0 '):
    110           pass
    111         elif line.startswith('#if ') or line.startswith('#ifdef '):
    112           failing_files.append(affected_file_path)
    113         break
    114 
    115   results = []
    116   if failing_files:
    117     results.append(
    118         output_api.PresubmitError(
    119             'The following files have #if or #ifdef before includes:\n%s\n\n'
    120             'See skbug.com/3362 for why this should be fixed.' %
    121                 '\n'.join(failing_files)))
    122   return results
    123 
    124 
    125 def _CopyrightChecks(input_api, output_api, source_file_filter=None):
    126   results = []
    127   year_pattern = r'\d{4}'
    128   year_range_pattern = r'%s(-%s)?' % (year_pattern, year_pattern)
    129   years_pattern = r'%s(,%s)*,?' % (year_range_pattern, year_range_pattern)
    130   copyright_pattern = (
    131       r'Copyright (\([cC]\) )?%s \w+' % years_pattern)
    132 
    133   for affected_file in input_api.AffectedSourceFiles(source_file_filter):
    134     if 'third_party' in affected_file.LocalPath():
    135       continue
    136     contents = input_api.ReadFile(affected_file, 'rb')
    137     if not re.search(copyright_pattern, contents):
    138       results.append(output_api.PresubmitError(
    139           '%s is missing a correct copyright header.' % affected_file))
    140   return results
    141 
    142 
    143 def _ToolFlags(input_api, output_api):
    144   """Make sure `{dm,nanobench}_flags.py test` passes if modified."""
    145   results = []
    146   sources = lambda x: ('dm_flags.py'        in x.LocalPath() or
    147                        'nanobench_flags.py' in x.LocalPath())
    148   for f in input_api.AffectedSourceFiles(sources):
    149     if 0 != subprocess.call(['python', f.LocalPath(), 'test']):
    150       results.append(output_api.PresubmitError('`python %s test` failed' % f))
    151   return results
    152 
    153 
    154 def _CommonChecks(input_api, output_api):
    155   """Presubmit checks common to upload and commit."""
    156   results = []
    157   sources = lambda x: (x.LocalPath().endswith('.h') or
    158                        x.LocalPath().endswith('.gypi') or
    159                        x.LocalPath().endswith('.gyp') or
    160                        x.LocalPath().endswith('.py') or
    161                        x.LocalPath().endswith('.sh') or
    162                        x.LocalPath().endswith('.m') or
    163                        x.LocalPath().endswith('.mm') or
    164                        x.LocalPath().endswith('.go') or
    165                        x.LocalPath().endswith('.c') or
    166                        x.LocalPath().endswith('.cc') or
    167                        x.LocalPath().endswith('.cpp'))
    168   results.extend(
    169       _CheckChangeHasEol(
    170           input_api, output_api, source_file_filter=sources))
    171   results.extend(_PythonChecks(input_api, output_api))
    172   results.extend(_IfDefChecks(input_api, output_api))
    173   results.extend(_CopyrightChecks(input_api, output_api,
    174                                   source_file_filter=sources))
    175   results.extend(_ToolFlags(input_api, output_api))
    176   return results
    177 
    178 
    179 def CheckChangeOnUpload(input_api, output_api):
    180   """Presubmit checks for the change on upload.
    181 
    182   The following are the presubmit checks:
    183   * Check change has one and only one EOL.
    184   """
    185   results = []
    186   results.extend(_CommonChecks(input_api, output_api))
    187   return results
    188 
    189 
    190 def _CheckTreeStatus(input_api, output_api, json_url):
    191   """Check whether to allow commit.
    192 
    193   Args:
    194     input_api: input related apis.
    195     output_api: output related apis.
    196     json_url: url to download json style status.
    197   """
    198   tree_status_results = input_api.canned_checks.CheckTreeIsOpen(
    199       input_api, output_api, json_url=json_url)
    200   if not tree_status_results:
    201     # Check for caution state only if tree is not closed.
    202     connection = input_api.urllib2.urlopen(json_url)
    203     status = input_api.json.loads(connection.read())
    204     connection.close()
    205     if ('caution' in status['message'].lower() and
    206         os.isatty(sys.stdout.fileno())):
    207       # Display a prompt only if we are in an interactive shell. Without this
    208       # check the commit queue behaves incorrectly because it considers
    209       # prompts to be failures.
    210       short_text = 'Tree state is: ' + status['general_state']
    211       long_text = status['message'] + '\n' + json_url
    212       tree_status_results.append(
    213           output_api.PresubmitPromptWarning(
    214               message=short_text, long_text=long_text))
    215   else:
    216     # Tree status is closed. Put in message about contacting sheriff.
    217     connection = input_api.urllib2.urlopen(
    218         SKIA_TREE_STATUS_URL + '/current-sheriff')
    219     sheriff_details = input_api.json.loads(connection.read())
    220     if sheriff_details:
    221       tree_status_results[0]._message += (
    222           '\n\nPlease contact the current Skia sheriff (%s) if you are trying '
    223           'to submit a build fix\nand do not know how to submit because the '
    224           'tree is closed') % sheriff_details['username']
    225   return tree_status_results
    226 
    227 
    228 def _CheckOwnerIsInAuthorsFile(input_api, output_api):
    229   results = []
    230   issue = input_api.change.issue
    231   if issue and input_api.rietveld:
    232     issue_properties = input_api.rietveld.get_issue_properties(
    233         issue=int(issue), messages=False)
    234     owner_email = issue_properties['owner_email']
    235 
    236     try:
    237       authors_content = ''
    238       for line in open(AUTHORS_FILE_NAME):
    239         if not line.startswith('#'):
    240           authors_content += line
    241       email_fnmatches = re.findall('<(.*)>', authors_content)
    242       for email_fnmatch in email_fnmatches:
    243         if fnmatch.fnmatch(owner_email, email_fnmatch):
    244           # Found a match, the user is in the AUTHORS file break out of the loop
    245           break
    246       else:
    247         # TODO(rmistry): Remove the below CLA messaging once a CLA checker has
    248         # been added to the CQ.
    249         results.append(
    250           output_api.PresubmitError(
    251             'The email %s is not in Skia\'s AUTHORS file.\n'
    252             'Issue owner, this CL must include an addition to the Skia AUTHORS '
    253             'file.\n'
    254             'Googler reviewers, please check that the AUTHORS entry '
    255             'corresponds to an email address in http://goto/cla-signers. If it '
    256             'does not then ask the issue owner to sign the CLA at '
    257             'https://developers.google.com/open-source/cla/individual '
    258             '(individual) or '
    259             'https://developers.google.com/open-source/cla/corporate '
    260             '(corporate).'
    261             % owner_email))
    262     except IOError:
    263       # Do not fail if authors file cannot be found.
    264       traceback.print_exc()
    265       input_api.logging.error('AUTHORS file not found!')
    266 
    267   return results
    268 
    269 
    270 def _CheckLGTMsForPublicAPI(input_api, output_api):
    271   """Check LGTMs for public API changes.
    272 
    273   For public API files make sure there is an LGTM from the list of owners in
    274   PUBLIC_API_OWNERS.
    275   """
    276   results = []
    277   requires_owner_check = False
    278   for affected_file in input_api.AffectedFiles():
    279     affected_file_path = affected_file.LocalPath()
    280     file_path, file_ext = os.path.splitext(affected_file_path)
    281     # We only care about files that end in .h and are under the top-level
    282     # include dir.
    283     if file_ext == '.h' and 'include' == file_path.split(os.path.sep)[0]:
    284       requires_owner_check = True
    285 
    286   if not requires_owner_check:
    287     return results
    288 
    289   lgtm_from_owner = False
    290   issue = input_api.change.issue
    291   if issue and input_api.rietveld:
    292     issue_properties = input_api.rietveld.get_issue_properties(
    293         issue=int(issue), messages=True)
    294     if re.match(REVERT_CL_SUBJECT_PREFIX, issue_properties['subject'], re.I):
    295       # It is a revert CL, ignore the public api owners check.
    296       return results
    297 
    298     # TODO(rmistry): Stop checking for COMMIT=false once crbug/470609 is
    299     # resolved.
    300     if issue_properties['cq_dry_run'] or re.search(
    301         r'^COMMIT=false$', issue_properties['description'], re.M):
    302       # Ignore public api owners check for dry run CLs since they are not
    303       # going to be committed.
    304       return results
    305 
    306     match = re.search(r'^TBR=(.*)$', issue_properties['description'], re.M)
    307     if match:
    308       tbr_entries = match.group(1).strip().split(',')
    309       for owner in PUBLIC_API_OWNERS:
    310         if owner in tbr_entries or owner.split('@')[0] in tbr_entries:
    311           # If an owner is specified in the TBR= line then ignore the public
    312           # api owners check.
    313           return results
    314 
    315     if issue_properties['owner_email'] in PUBLIC_API_OWNERS:
    316       # An owner created the CL that is an automatic LGTM.
    317       lgtm_from_owner = True
    318 
    319     messages = issue_properties.get('messages')
    320     if messages:
    321       for message in messages:
    322         if (message['sender'] in PUBLIC_API_OWNERS and
    323             'lgtm' in message['text'].lower()):
    324           # Found an lgtm in a message from an owner.
    325           lgtm_from_owner = True
    326           break
    327 
    328   if not lgtm_from_owner:
    329     results.append(
    330         output_api.PresubmitError(
    331             'Since the CL is editing public API, you must have an LGTM from '
    332             'one of: %s' % str(PUBLIC_API_OWNERS)))
    333   return results
    334 
    335 
    336 def PostUploadHook(cl, change, output_api):
    337   """git cl upload will call this hook after the issue is created/modified.
    338 
    339   This hook does the following:
    340   * Adds a link to preview docs changes if there are any docs changes in the CL.
    341   * Adds 'NOTRY=true' if the CL contains only docs changes.
    342   * Adds 'NOTREECHECKS=true' for non master branch changes since they do not
    343     need to be gated on the master branch's tree.
    344   * Adds 'NOTRY=true' for non master branch changes since trybots do not yet
    345     work on them.
    346   """
    347 
    348   results = []
    349   atleast_one_docs_change = False
    350   all_docs_changes = True
    351   for affected_file in change.AffectedFiles():
    352     affected_file_path = affected_file.LocalPath()
    353     file_path, _ = os.path.splitext(affected_file_path)
    354     if 'site' == file_path.split(os.path.sep)[0]:
    355       atleast_one_docs_change = True
    356     else:
    357       all_docs_changes = False
    358     if atleast_one_docs_change and not all_docs_changes:
    359       break
    360 
    361   issue = cl.issue
    362   rietveld_obj = cl.RpcServer()
    363   if issue and rietveld_obj:
    364     original_description = rietveld_obj.get_description(issue)
    365     new_description = original_description
    366 
    367     # If the change includes only doc changes then add NOTRY=true in the
    368     # CL's description if it does not exist yet.
    369     if all_docs_changes and not re.search(
    370         r'^NOTRY=true$', new_description, re.M | re.I):
    371       new_description += '\nNOTRY=true'
    372       results.append(
    373           output_api.PresubmitNotifyResult(
    374               'This change has only doc changes. Automatically added '
    375               '\'NOTRY=true\' to the CL\'s description'))
    376 
    377     # If there is atleast one docs change then add preview link in the CL's
    378     # description if it does not already exist there.
    379     if atleast_one_docs_change and not re.search(
    380         r'^DOCS_PREVIEW=.*', new_description, re.M | re.I):
    381       # Automatically add a link to where the docs can be previewed.
    382       new_description += '\nDOCS_PREVIEW= %s%s' % (DOCS_PREVIEW_URL, issue)
    383       results.append(
    384           output_api.PresubmitNotifyResult(
    385               'Automatically added a link to preview the docs changes to the '
    386               'CL\'s description'))
    387 
    388     # If the target ref is not master then add NOTREECHECKS=true and NOTRY=true
    389     # to the CL's description if it does not already exist there.
    390     target_ref = rietveld_obj.get_issue_properties(issue, False).get(
    391         'target_ref', '')
    392     if target_ref != 'refs/heads/master':
    393       if not re.search(
    394           r'^NOTREECHECKS=true$', new_description, re.M | re.I):
    395         new_description += "\nNOTREECHECKS=true"
    396         results.append(
    397             output_api.PresubmitNotifyResult(
    398                 'Branch changes do not need to rely on the master branch\'s '
    399                 'tree status. Automatically added \'NOTREECHECKS=true\' to the '
    400                 'CL\'s description'))
    401       if not re.search(
    402           r'^NOTRY=true$', new_description, re.M | re.I):
    403         new_description += "\nNOTRY=true"
    404         results.append(
    405             output_api.PresubmitNotifyResult(
    406                 'Trybots do not yet work for non-master branches. '
    407                 'Automatically added \'NOTRY=true\' to the CL\'s description'))
    408 
    409     # Read and process the HASHTAGS file.
    410     hashtags_fullpath = os.path.join(change._local_root, 'HASHTAGS')
    411     with open(hashtags_fullpath, 'rb') as hashtags_csv:
    412       hashtags_reader = csv.reader(hashtags_csv, delimiter=',')
    413       for row in hashtags_reader:
    414         if not row or row[0].startswith('#'):
    415           # Ignore empty lines and comments
    416           continue
    417         hashtag = row[0]
    418         # Search for the hashtag in the description.
    419         if re.search('#%s' % hashtag, new_description, re.M | re.I):
    420           for mapped_text in row[1:]:
    421             # Special case handling for CQ_KEYWORDS_THAT_NEED_APPENDING.
    422             appended_description = _HandleAppendingCQKeywords(
    423                 hashtag, mapped_text, new_description, results, output_api)
    424             if appended_description:
    425               new_description = appended_description
    426               continue
    427 
    428             # Add the mapped text if it does not already exist in the
    429             # CL's description.
    430             if not re.search(
    431                 r'^%s$' % mapped_text, new_description, re.M | re.I):
    432               new_description += '\n%s' % mapped_text
    433               results.append(
    434                   output_api.PresubmitNotifyResult(
    435                       'Found \'#%s\', automatically added \'%s\' to the CL\'s '
    436                       'description' % (hashtag, mapped_text)))
    437 
    438     # If the description has changed update it.
    439     if new_description != original_description:
    440       rietveld_obj.update_description(issue, new_description)
    441 
    442     return results
    443 
    444 
    445 def _HandleAppendingCQKeywords(hashtag, keyword_and_value, description,
    446                                results, output_api):
    447   """Handles the CQ keywords that need appending if specified in hashtags."""
    448   keyword = keyword_and_value.split('=')[0]
    449   if keyword in CQ_KEYWORDS_THAT_NEED_APPENDING:
    450     # If the keyword is already in the description then append to it.
    451     match = re.search(
    452         r'^%s=(.*)$' % keyword, description, re.M | re.I)
    453     if match:
    454       old_values = match.group(1).split(';')
    455       new_value = keyword_and_value.split('=')[1]
    456       if new_value in old_values:
    457         # Do not need to do anything here.
    458         return description
    459       # Update the description with the new values.
    460       new_description = description.replace(
    461           match.group(0), "%s;%s" % (match.group(0), new_value))
    462       results.append(
    463           output_api.PresubmitNotifyResult(
    464           'Found \'#%s\', automatically appended \'%s\' to %s in '
    465           'the CL\'s description' % (hashtag, new_value, keyword)))
    466       return new_description
    467   return None
    468 
    469 
    470 def CheckChangeOnCommit(input_api, output_api):
    471   """Presubmit checks for the change on commit.
    472 
    473   The following are the presubmit checks:
    474   * Check change has one and only one EOL.
    475   * Ensures that the Skia tree is open in
    476     http://skia-tree-status.appspot.com/. Shows a warning if it is in 'Caution'
    477     state and an error if it is in 'Closed' state.
    478   """
    479   results = []
    480   results.extend(_CommonChecks(input_api, output_api))
    481   results.extend(
    482       _CheckTreeStatus(input_api, output_api, json_url=(
    483           SKIA_TREE_STATUS_URL + '/banner-status?format=json')))
    484   results.extend(_CheckLGTMsForPublicAPI(input_api, output_api))
    485   results.extend(_CheckOwnerIsInAuthorsFile(input_api, output_api))
    486   return results
    487