• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
8See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
9for more details about the presubmit API built into gcl.
10"""
11
12import collections
13import csv
14import fnmatch
15import os
16import re
17import subprocess
18import sys
19import traceback
20
21
22REVERT_CL_SUBJECT_PREFIX = 'Revert '
23
24SKIA_TREE_STATUS_URL = 'http://skia-tree-status.appspot.com'
25
26# Please add the complete email address here (and not just 'xyz@' or 'xyz').
27PUBLIC_API_OWNERS = (
28    'mtklein@chromium.org',
29    'mtklein@google.com',
30    'reed@chromium.org',
31    'reed@google.com',
32    'bsalomon@chromium.org',
33    'bsalomon@google.com',
34    'djsollen@chromium.org',
35    'djsollen@google.com',
36    'hcm@chromium.org',
37    'hcm@google.com',
38)
39
40AUTHORS_FILE_NAME = 'AUTHORS'
41RELEASE_NOTES_FILE_NAME = 'RELEASE_NOTES.txt'
42
43DOCS_PREVIEW_URL = 'https://skia.org/?cl='
44GOLD_TRYBOT_URL = 'https://gold.skia.org/search?issue='
45
46SERVICE_ACCOUNT_SUFFIX = [
47    '@%s.iam.gserviceaccount.com' % project for project in [
48        'skia-buildbots.google.com', 'skia-swarming-bots', 'skia-public',
49        'skia-corp.google.com', 'chops-service-accounts']]
50
51
52def _CheckChangeHasEol(input_api, output_api, source_file_filter=None):
53  """Checks that files end with atleast one \n (LF)."""
54  eof_files = []
55  for f in input_api.AffectedSourceFiles(source_file_filter):
56    contents = input_api.ReadFile(f, 'rb')
57    # Check that the file ends in atleast one newline character.
58    if len(contents) > 1 and contents[-1:] != '\n':
59      eof_files.append(f.LocalPath())
60
61  if eof_files:
62    return [output_api.PresubmitPromptWarning(
63      'These files should end in a newline character:',
64      items=eof_files)]
65  return []
66
67
68def _JsonChecks(input_api, output_api):
69  """Run checks on any modified json files."""
70  failing_files = []
71  for affected_file in input_api.AffectedFiles(None):
72    affected_file_path = affected_file.LocalPath()
73    is_json = affected_file_path.endswith('.json')
74    is_metadata = (affected_file_path.startswith('site/') and
75                   affected_file_path.endswith('/METADATA'))
76    if is_json or is_metadata:
77      try:
78        input_api.json.load(open(affected_file_path, 'r'))
79      except ValueError:
80        failing_files.append(affected_file_path)
81
82  results = []
83  if failing_files:
84    results.append(
85        output_api.PresubmitError(
86            'The following files contain invalid json:\n%s\n\n' %
87                '\n'.join(failing_files)))
88  return results
89
90
91def _IfDefChecks(input_api, output_api):
92  """Ensures if/ifdef are not before includes. See skbug/3362 for details."""
93  comment_block_start_pattern = re.compile('^\s*\/\*.*$')
94  comment_block_middle_pattern = re.compile('^\s+\*.*')
95  comment_block_end_pattern = re.compile('^\s+\*\/.*$')
96  single_line_comment_pattern = re.compile('^\s*//.*$')
97  def is_comment(line):
98    return (comment_block_start_pattern.match(line) or
99            comment_block_middle_pattern.match(line) or
100            comment_block_end_pattern.match(line) or
101            single_line_comment_pattern.match(line))
102
103  empty_line_pattern = re.compile('^\s*$')
104  def is_empty_line(line):
105    return empty_line_pattern.match(line)
106
107  failing_files = []
108  for affected_file in input_api.AffectedSourceFiles(None):
109    affected_file_path = affected_file.LocalPath()
110    if affected_file_path.endswith('.cpp') or affected_file_path.endswith('.h'):
111      f = open(affected_file_path)
112      for line in f.xreadlines():
113        if is_comment(line) or is_empty_line(line):
114          continue
115        # The below will be the first real line after comments and newlines.
116        if line.startswith('#if 0 '):
117          pass
118        elif line.startswith('#if ') or line.startswith('#ifdef '):
119          failing_files.append(affected_file_path)
120        break
121
122  results = []
123  if failing_files:
124    results.append(
125        output_api.PresubmitError(
126            'The following files have #if or #ifdef before includes:\n%s\n\n'
127            'See https://bug.skia.org/3362 for why this should be fixed.' %
128                '\n'.join(failing_files)))
129  return results
130
131
132def _CopyrightChecks(input_api, output_api, source_file_filter=None):
133  results = []
134  year_pattern = r'\d{4}'
135  year_range_pattern = r'%s(-%s)?' % (year_pattern, year_pattern)
136  years_pattern = r'%s(,%s)*,?' % (year_range_pattern, year_range_pattern)
137  copyright_pattern = (
138      r'Copyright (\([cC]\) )?%s \w+' % years_pattern)
139
140  for affected_file in input_api.AffectedSourceFiles(source_file_filter):
141    if 'third_party' in affected_file.LocalPath():
142      continue
143    contents = input_api.ReadFile(affected_file, 'rb')
144    if not re.search(copyright_pattern, contents):
145      results.append(output_api.PresubmitError(
146          '%s is missing a correct copyright header.' % affected_file))
147  return results
148
149
150def _ToolFlags(input_api, output_api):
151  """Make sure `{dm,nanobench}_flags.py test` passes if modified."""
152  results = []
153  sources = lambda x: ('dm_flags.py'        in x.LocalPath() or
154                       'nanobench_flags.py' in x.LocalPath())
155  for f in input_api.AffectedSourceFiles(sources):
156    if 0 != subprocess.call(['python', f.LocalPath(), 'test']):
157      results.append(output_api.PresubmitError('`python %s test` failed' % f))
158  return results
159
160
161def _InfraTests(input_api, output_api):
162  """Run the infra tests."""
163  results = []
164  if not any(f.LocalPath().startswith('infra')
165             for f in input_api.AffectedFiles()):
166    return results
167
168  cmd = ['python', os.path.join('infra', 'bots', 'infra_tests.py')]
169  try:
170    subprocess.check_output(cmd)
171  except subprocess.CalledProcessError as e:
172    results.append(output_api.PresubmitError(
173        '`%s` failed:\n%s' % (' '.join(cmd), e.output)))
174  return results
175
176
177def _CheckGNFormatted(input_api, output_api):
178  """Make sure any .gn files we're changing have been formatted."""
179  results = []
180  for f in input_api.AffectedFiles():
181    if (not f.LocalPath().endswith('.gn') and
182        not f.LocalPath().endswith('.gni')):
183      continue
184
185    gn = 'gn.bat' if 'win32' in sys.platform else 'gn'
186    cmd = [gn, 'format', '--dry-run', f.LocalPath()]
187    try:
188      subprocess.check_output(cmd)
189    except subprocess.CalledProcessError:
190      fix = 'gn format ' + f.LocalPath()
191      results.append(output_api.PresubmitError(
192          '`%s` failed, try\n\t%s' % (' '.join(cmd), fix)))
193  return results
194
195def _CheckIncludesFormatted(input_api, output_api):
196  """Make sure #includes in files we're changing have been formatted."""
197  files = [str(f) for f in input_api.AffectedFiles() if f.Action() != 'D']
198  cmd = ['python',
199         'tools/rewrite_includes.py',
200         '--dry-run'] + files
201  if 0 != subprocess.call(cmd):
202    return [output_api.PresubmitError('`%s` failed' % ' '.join(cmd))]
203  return []
204
205def _CheckCompileIsolate(input_api, output_api):
206  """Ensure that gen_compile_isolate.py does not change compile.isolate."""
207  # Only run the check if files were added or removed.
208  results = []
209  script = os.path.join('infra', 'bots', 'gen_compile_isolate.py')
210  isolate = os.path.join('infra', 'bots', 'compile.isolated')
211  for f in input_api.AffectedFiles():
212    if f.Action() in ('A', 'D', 'R'):
213      break
214    if f.LocalPath() in (script, isolate):
215      break
216  else:
217    return results
218
219  cmd = ['python', script, 'test']
220  try:
221    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
222  except subprocess.CalledProcessError as e:
223    results.append(output_api.PresubmitError(e.output))
224  return results
225
226
227class _WarningsAsErrors():
228  def __init__(self, output_api):
229    self.output_api = output_api
230    self.old_warning = None
231  def __enter__(self):
232    self.old_warning = self.output_api.PresubmitPromptWarning
233    self.output_api.PresubmitPromptWarning = self.output_api.PresubmitError
234    return self.output_api
235  def __exit__(self, ex_type, ex_value, ex_traceback):
236    self.output_api.PresubmitPromptWarning = self.old_warning
237
238
239def _CheckDEPSValid(input_api, output_api):
240  """Ensure that DEPS contains valid entries."""
241  results = []
242  script = os.path.join('infra', 'bots', 'check_deps.py')
243  relevant_files = ('DEPS', script)
244  for f in input_api.AffectedFiles():
245    if f.LocalPath() in relevant_files:
246      break
247  else:
248    return results
249  cmd = ['python', script]
250  try:
251    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
252  except subprocess.CalledProcessError as e:
253    results.append(output_api.PresubmitError(e.output))
254  return results
255
256
257def _CommonChecks(input_api, output_api):
258  """Presubmit checks common to upload and commit."""
259  results = []
260  sources = lambda x: (x.LocalPath().endswith('.h') or
261                       x.LocalPath().endswith('.py') or
262                       x.LocalPath().endswith('.sh') or
263                       x.LocalPath().endswith('.m') or
264                       x.LocalPath().endswith('.mm') or
265                       x.LocalPath().endswith('.go') or
266                       x.LocalPath().endswith('.c') or
267                       x.LocalPath().endswith('.cc') or
268                       x.LocalPath().endswith('.cpp'))
269  results.extend(_CheckChangeHasEol(
270      input_api, output_api, source_file_filter=sources))
271  with _WarningsAsErrors(output_api):
272    results.extend(input_api.canned_checks.CheckChangeHasNoCR(
273        input_api, output_api, source_file_filter=sources))
274    results.extend(input_api.canned_checks.CheckChangeHasNoStrayWhitespace(
275        input_api, output_api, source_file_filter=sources))
276  results.extend(_JsonChecks(input_api, output_api))
277  results.extend(_IfDefChecks(input_api, output_api))
278  results.extend(_CopyrightChecks(input_api, output_api,
279                                  source_file_filter=sources))
280  results.extend(_ToolFlags(input_api, output_api))
281  results.extend(_CheckCompileIsolate(input_api, output_api))
282  results.extend(_CheckDEPSValid(input_api, output_api))
283  results.extend(_CheckIncludesFormatted(input_api, output_api))
284  return results
285
286
287def CheckChangeOnUpload(input_api, output_api):
288  """Presubmit checks for the change on upload.
289
290  The following are the presubmit checks:
291  * Check change has one and only one EOL.
292  """
293  results = []
294  results.extend(_CommonChecks(input_api, output_api))
295  # Run on upload, not commit, since the presubmit bot apparently doesn't have
296  # coverage or Go installed.
297  results.extend(_InfraTests(input_api, output_api))
298
299  results.extend(_CheckGNFormatted(input_api, output_api))
300  results.extend(_CheckReleaseNotesForPublicAPI(input_api, output_api))
301  return results
302
303
304def _CheckTreeStatus(input_api, output_api, json_url):
305  """Check whether to allow commit.
306
307  Args:
308    input_api: input related apis.
309    output_api: output related apis.
310    json_url: url to download json style status.
311  """
312  tree_status_results = input_api.canned_checks.CheckTreeIsOpen(
313      input_api, output_api, json_url=json_url)
314  if not tree_status_results:
315    # Check for caution state only if tree is not closed.
316    connection = input_api.urllib2.urlopen(json_url)
317    status = input_api.json.loads(connection.read())
318    connection.close()
319    if ('caution' in status['message'].lower() and
320        os.isatty(sys.stdout.fileno())):
321      # Display a prompt only if we are in an interactive shell. Without this
322      # check the commit queue behaves incorrectly because it considers
323      # prompts to be failures.
324      short_text = 'Tree state is: ' + status['general_state']
325      long_text = status['message'] + '\n' + json_url
326      tree_status_results.append(
327          output_api.PresubmitPromptWarning(
328              message=short_text, long_text=long_text))
329  else:
330    # Tree status is closed. Put in message about contacting sheriff.
331    connection = input_api.urllib2.urlopen(
332        SKIA_TREE_STATUS_URL + '/current-sheriff')
333    sheriff_details = input_api.json.loads(connection.read())
334    if sheriff_details:
335      tree_status_results[0]._message += (
336          '\n\nPlease contact the current Skia sheriff (%s) if you are trying '
337          'to submit a build fix\nand do not know how to submit because the '
338          'tree is closed') % sheriff_details['username']
339  return tree_status_results
340
341
342class CodeReview(object):
343  """Abstracts which codereview tool is used for the specified issue."""
344
345  def __init__(self, input_api):
346    self._issue = input_api.change.issue
347    self._gerrit = input_api.gerrit
348
349  def GetOwnerEmail(self):
350    return self._gerrit.GetChangeOwner(self._issue)
351
352  def GetSubject(self):
353    return self._gerrit.GetChangeInfo(self._issue)['subject']
354
355  def GetDescription(self):
356    return self._gerrit.GetChangeDescription(self._issue)
357
358  def IsDryRun(self):
359    return self._gerrit.GetChangeInfo(
360        self._issue)['labels']['Commit-Queue'].get('value', 0) == 1
361
362  def GetReviewers(self):
363    code_review_label = (
364        self._gerrit.GetChangeInfo(self._issue)['labels']['Code-Review'])
365    return [r['email'] for r in code_review_label.get('all', [])]
366
367  def GetApprovers(self):
368    approvers = []
369    code_review_label = (
370        self._gerrit.GetChangeInfo(self._issue)['labels']['Code-Review'])
371    for m in code_review_label.get('all', []):
372      if m.get("value") == 1:
373        approvers.append(m["email"])
374    return approvers
375
376
377def _CheckOwnerIsInAuthorsFile(input_api, output_api):
378  results = []
379  if input_api.change.issue:
380    cr = CodeReview(input_api)
381
382    owner_email = cr.GetOwnerEmail()
383
384    # Service accounts don't need to be in AUTHORS.
385    for suffix in SERVICE_ACCOUNT_SUFFIX:
386      if owner_email.endswith(suffix):
387        return results
388
389    try:
390      authors_content = ''
391      for line in open(AUTHORS_FILE_NAME):
392        if not line.startswith('#'):
393          authors_content += line
394      email_fnmatches = re.findall('<(.*)>', authors_content)
395      for email_fnmatch in email_fnmatches:
396        if fnmatch.fnmatch(owner_email, email_fnmatch):
397          # Found a match, the user is in the AUTHORS file break out of the loop
398          break
399      else:
400        results.append(
401          output_api.PresubmitError(
402            'The email %s is not in Skia\'s AUTHORS file.\n'
403            'Issue owner, this CL must include an addition to the Skia AUTHORS '
404            'file.'
405            % owner_email))
406    except IOError:
407      # Do not fail if authors file cannot be found.
408      traceback.print_exc()
409      input_api.logging.error('AUTHORS file not found!')
410
411  return results
412
413
414def _CheckReleaseNotesForPublicAPI(input_api, output_api):
415  """Checks to see if release notes file is updated with public API changes."""
416  results = []
417  public_api_changed = False
418  release_file_changed = False
419  for affected_file in input_api.AffectedFiles():
420    affected_file_path = affected_file.LocalPath()
421    file_path, file_ext = os.path.splitext(affected_file_path)
422    # We only care about files that end in .h and are under the top-level
423    # include dir, but not include/private.
424    if (file_ext == '.h' and
425        file_path.split(os.path.sep)[0] == 'include' and
426        'private' not in file_path):
427      public_api_changed = True
428    elif affected_file_path == RELEASE_NOTES_FILE_NAME:
429      release_file_changed = True
430
431  if public_api_changed and not release_file_changed:
432    results.append(output_api.PresubmitPromptWarning(
433        'If this change affects a client API, please add a summary line '
434        'to the %s file.' % RELEASE_NOTES_FILE_NAME))
435  return results
436
437
438
439def _CheckLGTMsForPublicAPI(input_api, output_api):
440  """Check LGTMs for public API changes.
441
442  For public API files make sure there is an LGTM from the list of owners in
443  PUBLIC_API_OWNERS.
444  """
445  results = []
446  requires_owner_check = False
447  for affected_file in input_api.AffectedFiles():
448    affected_file_path = affected_file.LocalPath()
449    file_path, file_ext = os.path.splitext(affected_file_path)
450    # We only care about files that end in .h and are under the top-level
451    # include dir, but not include/private.
452    if (file_ext == '.h' and
453        'include' == file_path.split(os.path.sep)[0] and
454        'private' not in file_path):
455      requires_owner_check = True
456
457  if not requires_owner_check:
458    return results
459
460  lgtm_from_owner = False
461  if input_api.change.issue:
462    cr = CodeReview(input_api)
463
464    if re.match(REVERT_CL_SUBJECT_PREFIX, cr.GetSubject(), re.I):
465      # It is a revert CL, ignore the public api owners check.
466      return results
467
468    if cr.IsDryRun():
469      # Ignore public api owners check for dry run CLs since they are not
470      # going to be committed.
471      return results
472
473    if input_api.gerrit:
474      for reviewer in cr.GetReviewers():
475        if reviewer in PUBLIC_API_OWNERS:
476          # If an owner is specified as an reviewer in Gerrit then ignore the
477          # public api owners check.
478          return results
479    else:
480      match = re.search(r'^TBR=(.*)$', cr.GetDescription(), re.M)
481      if match:
482        tbr_section = match.group(1).strip().split(' ')[0]
483        tbr_entries = tbr_section.split(',')
484        for owner in PUBLIC_API_OWNERS:
485          if owner in tbr_entries or owner.split('@')[0] in tbr_entries:
486            # If an owner is specified in the TBR= line then ignore the public
487            # api owners check.
488            return results
489
490    if cr.GetOwnerEmail() in PUBLIC_API_OWNERS:
491      # An owner created the CL that is an automatic LGTM.
492      lgtm_from_owner = True
493
494    for approver in cr.GetApprovers():
495      if approver in PUBLIC_API_OWNERS:
496        # Found an lgtm in a message from an owner.
497        lgtm_from_owner = True
498        break
499
500  if not lgtm_from_owner:
501    results.append(
502        output_api.PresubmitError(
503            "If this CL adds to or changes Skia's public API, you need an LGTM "
504            "from any of %s.  If this CL only removes from or doesn't change "
505            "Skia's public API, please add a short note to the CL saying so. "
506            "Add one of the owners as a reviewer to your CL as well as to the "
507            "TBR= line.  If you don't know if this CL affects Skia's public "
508            "API, treat it like it does." % str(PUBLIC_API_OWNERS)))
509  return results
510
511
512def _FooterExists(footers, key, value):
513  for k, v in footers:
514    if k == key and v == value:
515      return True
516  return False
517
518
519def PostUploadHook(cl, change, output_api):
520  """git cl upload will call this hook after the issue is created/modified.
521
522  This hook does the following:
523  * Adds a link to preview docs changes if there are any docs changes in the CL.
524  * Adds 'No-Try: true' if the CL contains only docs changes.
525  """
526
527  results = []
528  atleast_one_docs_change = False
529  all_docs_changes = True
530  for affected_file in change.AffectedFiles():
531    affected_file_path = affected_file.LocalPath()
532    file_path, _ = os.path.splitext(affected_file_path)
533    if 'site' == file_path.split(os.path.sep)[0]:
534      atleast_one_docs_change = True
535    else:
536      all_docs_changes = False
537    if atleast_one_docs_change and not all_docs_changes:
538      break
539
540  issue = cl.issue
541  if issue:
542    # Skip PostUploadHooks for all auto-commit service account bots. New
543    # patchsets (caused due to PostUploadHooks) invalidates the CQ+2 vote from
544    # the "--use-commit-queue" flag to "git cl upload".
545    for suffix in SERVICE_ACCOUNT_SUFFIX:
546      if cl.GetIssueOwner().endswith(suffix):
547        return results
548
549    original_description_lines, footers = cl.GetDescriptionFooters()
550    new_description_lines = list(original_description_lines)
551
552    # If the change includes only doc changes then add No-Try: true in the
553    # CL's description if it does not exist yet.
554    if all_docs_changes and not _FooterExists(footers, 'No-Try', 'true'):
555      new_description_lines.append('No-Try: true')
556      results.append(
557          output_api.PresubmitNotifyResult(
558              'This change has only doc changes. Automatically added '
559              '\'No-Try: true\' to the CL\'s description'))
560
561    # If there is atleast one docs change then add preview link in the CL's
562    # description if it does not already exist there.
563    docs_preview_link = '%s%s' % (DOCS_PREVIEW_URL, issue)
564    docs_preview_line = 'Docs-Preview: %s' % docs_preview_link
565    if (atleast_one_docs_change and
566        not _FooterExists(footers, 'Docs-Preview', docs_preview_link)):
567      # Automatically add a link to where the docs can be previewed.
568      new_description_lines.append(docs_preview_line)
569      results.append(
570          output_api.PresubmitNotifyResult(
571              'Automatically added a link to preview the docs changes to the '
572              'CL\'s description'))
573
574    # If the description has changed update it.
575    if new_description_lines != original_description_lines:
576      # Add a new line separating the new contents from the old contents.
577      new_description_lines.insert(len(original_description_lines), '')
578      cl.UpdateDescriptionFooters(new_description_lines, footers)
579
580    return results
581
582
583def CheckChangeOnCommit(input_api, output_api):
584  """Presubmit checks for the change on commit.
585
586  The following are the presubmit checks:
587  * Check change has one and only one EOL.
588  * Ensures that the Skia tree is open in
589    http://skia-tree-status.appspot.com/. Shows a warning if it is in 'Caution'
590    state and an error if it is in 'Closed' state.
591  """
592  results = []
593  results.extend(_CommonChecks(input_api, output_api))
594  results.extend(
595      _CheckTreeStatus(input_api, output_api, json_url=(
596          SKIA_TREE_STATUS_URL + '/banner-status?format=json')))
597  results.extend(_CheckLGTMsForPublicAPI(input_api, output_api))
598  results.extend(_CheckOwnerIsInAuthorsFile(input_api, output_api))
599  # Checks for the presence of 'DO NOT''SUBMIT' in CL description and in
600  # content of files.
601  results.extend(
602      input_api.canned_checks.CheckDoNotSubmit(input_api, output_api))
603  return results
604