• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2# Copyright (c) 2013 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6
7"""Top-level presubmit script for Skia.
8
9See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
10for more details about the presubmit API built into gcl.
11"""
12
13import fnmatch
14import os
15import re
16import subprocess
17import sys
18import traceback
19
20
21RELEASE_NOTES_DIR = 'relnotes'
22RELEASE_NOTES_FILE_NAME = 'RELEASE_NOTES.md'
23RELEASE_NOTES_README = '//relnotes/README.md'
24
25GOLD_TRYBOT_URL = 'https://gold.skia.org/search?issue='
26
27SERVICE_ACCOUNT_SUFFIX = [
28    '@%s.iam.gserviceaccount.com' % project for project in [
29        'skia-buildbots.google.com', 'skia-swarming-bots', 'skia-public',
30        'skia-corp.google.com', 'chops-service-accounts']]
31
32USE_PYTHON3 = True
33
34
35def _CheckChangeHasEol(input_api, output_api, source_file_filter=None):
36  """Checks that files end with at least one \n (LF)."""
37  eof_files = []
38  for f in input_api.AffectedSourceFiles(source_file_filter):
39    contents = input_api.ReadFile(f, 'rb')
40    # Check that the file ends in at least one newline character.
41    if len(contents) > 1 and contents[-1:] != '\n':
42      eof_files.append(f.LocalPath())
43
44  if eof_files:
45    return [output_api.PresubmitPromptWarning(
46      'These files should end in a newline character:',
47      items=eof_files)]
48  return []
49
50
51def _JsonChecks(input_api, output_api):
52  """Run checks on any modified json files."""
53  failing_files = []
54  for affected_file in input_api.AffectedFiles(None):
55    affected_file_path = affected_file.LocalPath()
56    is_json = affected_file_path.endswith('.json')
57    is_metadata = (affected_file_path.startswith('site/') and
58                   affected_file_path.endswith('/METADATA'))
59    if is_json or is_metadata:
60      try:
61        input_api.json.load(open(affected_file_path, 'r'))
62      except ValueError:
63        failing_files.append(affected_file_path)
64
65  results = []
66  if failing_files:
67    results.append(
68        output_api.PresubmitError(
69            'The following files contain invalid json:\n%s\n\n' %
70                '\n'.join(failing_files)))
71  return results
72
73
74def _IfDefChecks(input_api, output_api):
75  """Ensures if/ifdef are not before includes. See skbug/3362 for details."""
76  comment_block_start_pattern = re.compile('^\s*\/\*.*$')
77  comment_block_middle_pattern = re.compile('^\s+\*.*')
78  comment_block_end_pattern = re.compile('^\s+\*\/.*$')
79  single_line_comment_pattern = re.compile('^\s*//.*$')
80  def is_comment(line):
81    return (comment_block_start_pattern.match(line) or
82            comment_block_middle_pattern.match(line) or
83            comment_block_end_pattern.match(line) or
84            single_line_comment_pattern.match(line))
85
86  empty_line_pattern = re.compile('^\s*$')
87  def is_empty_line(line):
88    return empty_line_pattern.match(line)
89
90  failing_files = []
91  for affected_file in input_api.AffectedSourceFiles(None):
92    affected_file_path = affected_file.LocalPath()
93    if affected_file_path.endswith('.cpp') or affected_file_path.endswith('.h'):
94      f = open(affected_file_path)
95      for line in f:
96        if is_comment(line) or is_empty_line(line):
97          continue
98        # The below will be the first real line after comments and newlines.
99        if line.startswith('#if 0 '):
100          pass
101        elif line.startswith('#if ') or line.startswith('#ifdef '):
102          failing_files.append(affected_file_path)
103        break
104
105  results = []
106  if failing_files:
107    results.append(
108        output_api.PresubmitError(
109            'The following files have #if or #ifdef before includes:\n%s\n\n'
110            'See https://bug.skia.org/3362 for why this should be fixed.' %
111                '\n'.join(failing_files)))
112  return results
113
114
115def _CopyrightChecks(input_api, output_api, source_file_filter=None):
116  results = []
117  year_pattern = r'\d{4}'
118  year_range_pattern = r'%s(-%s)?' % (year_pattern, year_pattern)
119  years_pattern = r'%s(,%s)*,?' % (year_range_pattern, year_range_pattern)
120  copyright_pattern = (
121      r'Copyright (\([cC]\) )?%s \w+' % years_pattern)
122
123  for affected_file in input_api.AffectedSourceFiles(source_file_filter):
124    if ('third_party/' in affected_file.LocalPath() or
125        'tests/sksl/' in affected_file.LocalPath() or
126        'bazel/rbe/' in affected_file.LocalPath() or
127        'bazel/external/' in affected_file.LocalPath() or
128        'bazel/exporter/interfaces/mocks/' in affected_file.LocalPath()):
129      continue
130    contents = input_api.ReadFile(affected_file, 'rb')
131    if not re.search(copyright_pattern, contents):
132      results.append(output_api.PresubmitError(
133          '%s is missing a correct copyright header.' % affected_file))
134  return results
135
136
137def _InfraTests(input_api, output_api):
138  """Run the infra tests."""
139  results = []
140  if not any(f.LocalPath().startswith('infra')
141             for f in input_api.AffectedFiles()):
142    return results
143
144  cmd = ['python3', os.path.join('infra', 'bots', 'infra_tests.py')]
145  try:
146    subprocess.check_output(cmd)
147  except subprocess.CalledProcessError as e:
148    results.append(output_api.PresubmitError(
149        '`%s` failed:\n%s' % (' '.join(cmd), e.output)))
150  return results
151
152
153def _CheckGNFormatted(input_api, output_api):
154  """Make sure any .gn files we're changing have been formatted."""
155  files = []
156  for f in input_api.AffectedFiles(include_deletes=False):
157    if (f.LocalPath().endswith('.gn') or
158        f.LocalPath().endswith('.gni')):
159      files.append(f)
160  if not files:
161    return []
162
163  cmd = ['python3', os.path.join('bin', 'fetch-gn')]
164  try:
165    subprocess.check_output(cmd)
166  except subprocess.CalledProcessError as e:
167    return [output_api.PresubmitError(
168        '`%s` failed:\n%s' % (' '.join(cmd), e.output))]
169
170  results = []
171  for f in files:
172    gn = 'gn.exe' if 'win32' in sys.platform else 'gn'
173    gn = os.path.join(input_api.PresubmitLocalPath(), 'bin', gn)
174    cmd = [gn, 'format', '--dry-run', f.LocalPath()]
175    try:
176      subprocess.check_output(cmd)
177    except subprocess.CalledProcessError:
178      fix = 'bin/gn format ' + f.LocalPath()
179      results.append(output_api.PresubmitError(
180          '`%s` failed, try\n\t%s' % (' '.join(cmd), fix)))
181  return results
182
183
184def _CheckGitConflictMarkers(input_api, output_api):
185  pattern = input_api.re.compile('^(?:<<<<<<<|>>>>>>>) |^=======$')
186  results = []
187  for f in input_api.AffectedFiles():
188    for line_num, line in f.ChangedContents():
189      if f.LocalPath().endswith('.md'):
190        # First-level headers in markdown look a lot like version control
191        # conflict markers. http://daringfireball.net/projects/markdown/basics
192        continue
193      if pattern.match(line):
194        results.append(
195            output_api.PresubmitError(
196                'Git conflict markers found in %s:%d %s' % (
197                    f.LocalPath(), line_num, line)))
198  return results
199
200
201def _CheckIncludesFormatted(input_api, output_api):
202  """Make sure #includes in files we're changing have been formatted."""
203  files = [str(f) for f in input_api.AffectedFiles() if f.Action() != 'D']
204  cmd = ['python3',
205         'tools/rewrite_includes.py',
206         '--dry-run'] + files
207  if 0 != subprocess.call(cmd):
208    return [output_api.PresubmitError('`%s` failed' % ' '.join(cmd))]
209  return []
210
211
212class _WarningsAsErrors():
213  def __init__(self, output_api):
214    self.output_api = output_api
215    self.old_warning = None
216  def __enter__(self):
217    self.old_warning = self.output_api.PresubmitPromptWarning
218    self.output_api.PresubmitPromptWarning = self.output_api.PresubmitError
219    return self.output_api
220  def __exit__(self, ex_type, ex_value, ex_traceback):
221    self.output_api.PresubmitPromptWarning = self.old_warning
222
223
224def _RegenerateAllExamplesCPP(input_api, output_api):
225  """Regenerates all_examples.cpp if an example was added or deleted."""
226  if not any(f.LocalPath().startswith('docs/examples/')
227             for f in input_api.AffectedFiles()):
228    return []
229  command_str = 'tools/fiddle/make_all_examples_cpp.py'
230  cmd = ['python3', command_str]
231  if 0 != subprocess.call(cmd):
232    return [output_api.PresubmitError('`%s` failed' % ' '.join(cmd))]
233
234  results = []
235  git_diff_output = input_api.subprocess.check_output(
236      ['git', 'diff', '--no-ext-diff'])
237  if git_diff_output:
238    results += [output_api.PresubmitError(
239        'Diffs found after running "%s":\n\n%s\n'
240        'Please commit or discard the above changes.' % (
241            command_str,
242            git_diff_output,
243        )
244    )]
245  return results
246
247
248def _CheckExamplesForPrivateAPIs(input_api, output_api):
249  """We only want our checked-in examples (aka fiddles) to show public API."""
250  banned_includes = [
251    input_api.re.compile(r'#\s*include\s+("src/.*)'),
252    input_api.re.compile(r'#\s*include\s+("include/private/.*)'),
253  ]
254  file_filter = lambda x: (x.LocalPath().startswith('docs/examples/'))
255  errors = []
256  for affected_file in input_api.AffectedSourceFiles(file_filter):
257    affected_filepath = affected_file.LocalPath()
258    for (line_num, line) in affected_file.ChangedContents():
259      for re in banned_includes:
260        match = re.search(line)
261        if match:
262          errors.append('%s:%s: Fiddles should not use private/internal API like %s.' % (
263                affected_filepath, line_num, match.group(1)))
264
265  if errors:
266    return [output_api.PresubmitError('\n'.join(errors))]
267  return []
268
269
270def _CheckGeneratedBazelBUILDFiles(input_api, output_api):
271    if 'win32' in sys.platform:
272      # TODO(crbug.com/skia/12541): Remove when Bazel builds work on Windows.
273      # Note: `make` is not installed on Windows by default.
274      return []
275    if 'darwin' in sys.platform:
276      # This takes too long on Mac with default settings. Probably due to sandboxing.
277      return []
278    for affected_file in input_api.AffectedFiles(include_deletes=True):
279      affected_file_path = affected_file.LocalPath()
280      if (affected_file_path.endswith('.go') or
281          affected_file_path.endswith('BUILD.bazel')):
282        return _RunCommandAndCheckGitDiff(output_api,
283                                          ['make', '-C', 'bazel', 'generate_go'])
284    return []  # No modified Go source files.
285
286
287def _CheckBazelBUILDFiles(input_api, output_api):
288  """Makes sure our BUILD.bazel files are compatible with G3."""
289  results = []
290  for affected_file in input_api.AffectedFiles(include_deletes=False):
291    affected_file_path = affected_file.LocalPath()
292    is_bazel = affected_file_path.endswith('BUILD.bazel')
293    # This list lines up with the one in autoroller_lib.py (see G3).
294    excluded_paths = ["infra/", "bazel/rbe/", "bazel/external/", "bazel/common_config_settings/",
295                      "modules/canvaskit/go/", "experimental/", "bazel/platform", "third_party/",
296                      "tests/", "resources/", "bazel/deps_parser/", "bazel/exporter_tool/",
297                      "tools/gpu/gl/interface/", "bazel/utils/", "include/config/",
298                      "bench/", "example/external_client/"]
299    is_excluded = any(affected_file_path.startswith(n) for n in excluded_paths)
300    if is_bazel and not is_excluded:
301      with open(affected_file_path, 'r') as file:
302        contents = file.read()
303        if 'exports_files_legacy(' not in contents:
304          results.append(output_api.PresubmitError(
305            ('%s needs to call exports_files_legacy() to support legacy G3 ' +
306             'rules.\nPut this near the top of the file, beneath ' +
307             'licenses(["notice"]).') % affected_file_path
308          ))
309        if 'licenses(["notice"])' not in contents:
310          results.append(output_api.PresubmitError(
311            ('%s needs to have\nlicenses(["notice"])\nimmediately after ' +
312             'the load() calls to comply with G3 policies.') % affected_file_path
313          ))
314        if 'cc_library(' in contents and '"skia_cc_library"' not in contents:
315          results.append(output_api.PresubmitError(
316            ('%s needs to load skia_cc_library from macros.bzl instead of using the ' +
317             'native one. This allows us to build differently for G3.\n' +
318             'Add "skia_cc_library" to load("//bazel:macros.bzl", ...)')
319            % affected_file_path
320          ))
321        if 'default_applicable_licenses' not in contents:
322          # See https://opensource.google/documentation/reference/thirdparty/new_license_rules
323          results.append(output_api.PresubmitError(
324            ('%s needs to have\npackage(default_applicable_licenses = ["//:license"])\n'+
325             'to comply with G3 policies') % affected_file_path
326          ))
327  return results
328
329
330def _RunCommandAndCheckGitDiff(output_api, command):
331  """Run an arbitrary command. Fail if it produces any diffs."""
332  command_str = ' '.join(command)
333  results = []
334
335  try:
336    output = subprocess.check_output(
337        command,
338        stderr=subprocess.STDOUT, encoding='utf-8')
339  except subprocess.CalledProcessError as e:
340    results += [output_api.PresubmitError(
341        'Command "%s" returned non-zero exit code %d. Output: \n\n%s' % (
342            command_str,
343            e.returncode,
344            e.output,
345        )
346    )]
347
348  git_diff_output = subprocess.check_output(
349      ['git', 'diff', '--no-ext-diff'], encoding='utf-8')
350  if git_diff_output:
351    results += [output_api.PresubmitError(
352        'Diffs found after running "%s":\n\n%s\n'
353        'Please commit or discard the above changes.' % (
354            command_str,
355            git_diff_output,
356        )
357    )]
358
359  return results
360
361
362def _CheckGNIGenerated(input_api, output_api):
363  """Ensures that the generated *.gni files are current.
364
365  The Bazel project files are authoritative and some *.gni files are
366  generated from them using the exporter_tool. This check ensures they
367  are still current.
368  """
369  if 'win32' in sys.platform:
370    # TODO(crbug.com/skia/12541): Remove when Bazel builds work on Windows.
371    # Note: `make` is not installed on Windows by default.
372    return [
373        output_api.PresubmitPromptWarning(
374            'Skipping Bazel=>GNI export check on Windows (unsupported platform).'
375        )
376    ]
377  if 'darwin' in sys.platform:
378      # This takes too long on Mac with default settings. Probably due to sandboxing.
379      return []
380  should_run = False
381  for affected_file in input_api.AffectedFiles(include_deletes=True):
382    affected_file_path = affected_file.LocalPath()
383    if affected_file_path.endswith('BUILD.bazel') or affected_file_path.endswith('.gni'):
384      should_run = True
385  # Generate GNI files and verify no changes.
386  if should_run:
387    return _RunCommandAndCheckGitDiff(output_api,
388            ['make', '-C', 'bazel', 'generate_gni'])
389
390  # No Bazel build files changed.
391  return []
392
393
394def _CheckBuildifier(input_api, output_api):
395  """Runs Buildifier and fails on linting errors, or if it produces any diffs.
396
397  This check only runs if the affected files include any WORKSPACE, BUILD,
398  BUILD.bazel or *.bzl files.
399  """
400  files = []
401  # Please keep the below exclude patterns in sync with those in the //:buildifier rule definition.
402  for affected_file in input_api.AffectedFiles(include_deletes=False):
403    affected_file_path = affected_file.LocalPath()
404    if affected_file_path.endswith('BUILD.bazel') or affected_file_path.endswith('.bzl'):
405      if not affected_file_path.endswith('public.bzl') and \
406        not affected_file_path.endswith('go_repositories.bzl') and \
407        not "bazel/rbe/gce_linux/" in affected_file_path and \
408        not affected_file_path.startswith("third_party/externals/") and \
409        not "node_modules/" in affected_file_path:  # Skip generated files.
410        files.append(affected_file_path)
411  if not files:
412    return []
413  try:
414    subprocess.check_output(
415        ['buildifier', '--version'],
416        stderr=subprocess.STDOUT)
417  except:
418    return [output_api.PresubmitNotifyResult(
419      'Skipping buildifier check because it is not on PATH. \n' +
420      'You can download it from https://github.com/bazelbuild/buildtools/releases')]
421
422  return _RunCommandAndCheckGitDiff(
423    # One can change --lint=warn to --lint=fix to have things automatically fixed where possible.
424    # However, --lint=fix will not cause a presubmit error if there are things that require
425    # manual intervention, so we leave --lint=warn on by default.
426    #
427    # Please keep the below arguments in sync with those in the //:buildifier rule definition.
428    output_api, [
429      'buildifier',
430      '--mode=fix',
431      '--lint=warn',
432      '--warnings',
433      ','.join([
434        '-native-android',
435        '-native-cc',
436        '-native-py',
437      ])
438    ] + files)
439
440
441def _CheckBannedAPIs(input_api, output_api):
442  """Check source code for functions and packages that should not be used."""
443
444  # A list of tuples of a regex to match an API and a suggested replacement for
445  # that API. There is an optional third parameter for files which *can* use this
446  # API without warning.
447  banned_replacements = [
448    (r'std::stof\(', 'std::strtof(), which does not throw'),
449    (r'std::stod\(', 'std::strtod(), which does not throw'),
450    (r'std::stold\(', 'std::strtold(), which does not throw'),
451  ]
452
453  # These defines are either there or not, and using them with just an #if is a
454  # subtle, frustrating bug.
455  existence_defines = ['SK_GANESH', 'SK_GRAPHITE', 'SK_GL', 'SK_VULKAN', 'SK_DAWN', 'SK_METAL',
456                       'SK_DIRECT3D', 'SK_DEBUG', 'GR_TEST_UTILS', 'GRAPHITE_TEST_UTILS']
457  for d in existence_defines:
458    banned_replacements.append(('#if {}'.format(d),
459                                '#if defined({})'.format(d)))
460  compiled_replacements = []
461  for rep in banned_replacements:
462    exceptions = []
463    if len(rep) == 3:
464      (re, replacement, exceptions) = rep
465    else:
466      (re, replacement) = rep
467
468    compiled_re = input_api.re.compile(re)
469    compiled_exceptions = [input_api.re.compile(exc) for exc in exceptions]
470    compiled_replacements.append(
471        (compiled_re, replacement, compiled_exceptions))
472
473  errors = []
474  file_filter = lambda x: (x.LocalPath().endswith('.h') or
475                           x.LocalPath().endswith('.cpp') or
476                           x.LocalPath().endswith('.cc') or
477                           x.LocalPath().endswith('.m') or
478                           x.LocalPath().endswith('.mm'))
479  for affected_file in input_api.AffectedSourceFiles(file_filter):
480    affected_filepath = affected_file.LocalPath()
481    for (line_num, line) in affected_file.ChangedContents():
482      for (re, replacement, exceptions) in compiled_replacements:
483        match = re.search(line)
484        if match:
485          for exc in exceptions:
486            if exc.search(affected_filepath):
487              break
488          else:
489            errors.append('%s:%s: Instead of %s, please use %s.' % (
490                affected_filepath, line_num, match.group(), replacement))
491
492  if errors:
493    return [output_api.PresubmitError('\n'.join(errors))]
494
495  return []
496
497
498def _CheckDEPS(input_api, output_api):
499  """If DEPS was modified, run the deps_parser to update bazel/deps.bzl"""
500  needs_running = False
501  for affected_file in input_api.AffectedFiles(include_deletes=False):
502    affected_file_path = affected_file.LocalPath()
503    if affected_file_path.endswith('DEPS') or affected_file_path.endswith('deps.bzl'):
504      needs_running = True
505      break
506  if not needs_running:
507    return []
508  try:
509    subprocess.check_output(
510        ['bazelisk', '--version'],
511        stderr=subprocess.STDOUT)
512  except:
513    return [output_api.PresubmitNotifyResult(
514      'Skipping DEPS check because bazelisk is not on PATH. \n' +
515      'You can download it from https://github.com/bazelbuild/bazelisk/releases/tag/v1.14.0')]
516
517  return _RunCommandAndCheckGitDiff(
518    output_api, ['bazelisk', 'run', '//bazel/deps_parser'])
519
520
521def _CommonChecks(input_api, output_api):
522  """Presubmit checks common to upload and commit."""
523  results = []
524  sources = lambda x: (x.LocalPath().endswith('.h') or
525                       x.LocalPath().endswith('.py') or
526                       x.LocalPath().endswith('.sh') or
527                       x.LocalPath().endswith('.m') or
528                       x.LocalPath().endswith('.mm') or
529                       x.LocalPath().endswith('.go') or
530                       x.LocalPath().endswith('.c') or
531                       x.LocalPath().endswith('.cc') or
532                       x.LocalPath().endswith('.cpp'))
533  results.extend(_CheckChangeHasEol(
534      input_api, output_api, source_file_filter=sources))
535  with _WarningsAsErrors(output_api):
536    results.extend(input_api.canned_checks.CheckChangeHasNoCR(
537        input_api, output_api, source_file_filter=sources))
538    results.extend(input_api.canned_checks.CheckChangeHasNoStrayWhitespace(
539        input_api, output_api, source_file_filter=sources))
540  results.extend(_JsonChecks(input_api, output_api))
541  results.extend(_IfDefChecks(input_api, output_api))
542  results.extend(_CopyrightChecks(input_api, output_api,
543                                  source_file_filter=sources))
544  results.extend(_CheckIncludesFormatted(input_api, output_api))
545  results.extend(_CheckGNFormatted(input_api, output_api))
546  results.extend(_CheckGitConflictMarkers(input_api, output_api))
547  results.extend(_RegenerateAllExamplesCPP(input_api, output_api))
548  results.extend(_CheckExamplesForPrivateAPIs(input_api, output_api))
549  results.extend(_CheckBazelBUILDFiles(input_api, output_api))
550  results.extend(_CheckBannedAPIs(input_api, output_api))
551  return results
552
553
554def CheckChangeOnUpload(input_api, output_api):
555  """Presubmit checks for the change on upload."""
556  results = []
557  results.extend(_CommonChecks(input_api, output_api))
558  # Run on upload, not commit, since the presubmit bot apparently doesn't have
559  # coverage or Go installed.
560  results.extend(_InfraTests(input_api, output_api))
561  results.extend(_CheckTopReleaseNotesChanged(input_api, output_api))
562  results.extend(_CheckReleaseNotesForPublicAPI(input_api, output_api))
563  # Buildifier might not be on the CI machines.
564  results.extend(_CheckBuildifier(input_api, output_api))
565  # We don't want this to block the CQ (for now).
566  results.extend(_CheckDEPS(input_api, output_api))
567  # Bazelisk is not yet included in the Presubmit job.
568  results.extend(_CheckGeneratedBazelBUILDFiles(input_api, output_api))
569  results.extend(_CheckGNIGenerated(input_api, output_api))
570  return results
571
572
573class CodeReview(object):
574  """Abstracts which codereview tool is used for the specified issue."""
575
576  def __init__(self, input_api):
577    self._issue = input_api.change.issue
578    self._gerrit = input_api.gerrit
579
580  def GetOwnerEmail(self):
581    return self._gerrit.GetChangeOwner(self._issue)
582
583  def GetSubject(self):
584    return self._gerrit.GetChangeInfo(self._issue)['subject']
585
586  def GetDescription(self):
587    return self._gerrit.GetChangeDescription(self._issue)
588
589  def GetReviewers(self):
590    code_review_label = (
591        self._gerrit.GetChangeInfo(self._issue)['labels']['Code-Review'])
592    return [r['email'] for r in code_review_label.get('all', [])]
593
594  def GetApprovers(self):
595    approvers = []
596    code_review_label = (
597        self._gerrit.GetChangeInfo(self._issue)['labels']['Code-Review'])
598    for m in code_review_label.get('all', []):
599      if m.get("value") == 1:
600        approvers.append(m["email"])
601    return approvers
602
603
604def _CheckReleaseNotesForPublicAPI(input_api, output_api):
605  """Checks to see if a release notes file is added or edited with public API changes."""
606  results = []
607  public_api_changed = False
608  release_file_changed = False
609  for affected_file in input_api.AffectedFiles():
610    affected_file_path = affected_file.LocalPath()
611    file_path, file_ext = os.path.splitext(affected_file_path)
612    # We only care about files that end in .h and are under the top-level
613    # include dir, but not include/private.
614    if (file_ext == '.h' and
615        file_path.split(os.path.sep)[0] == 'include' and
616        'private' not in file_path):
617      public_api_changed = True
618    elif os.path.dirname(file_path) == RELEASE_NOTES_DIR:
619      release_file_changed = True
620
621  if public_api_changed and not release_file_changed:
622    results.append(output_api.PresubmitPromptWarning(
623        'If this change affects a client API, please add a new summary '
624        'file in the %s directory. More information can be found in '
625        '%s.' % (RELEASE_NOTES_DIR, RELEASE_NOTES_README)))
626  return results
627
628
629def _CheckTopReleaseNotesChanged(input_api, output_api):
630  """Warns if the top level release notes file was changed.
631
632  The top level file is now auto-edited, and new release notes should
633  be added to the RELEASE_NOTES_DIR directory"""
634  results = []
635  top_relnotes_changed = False
636  release_file_changed = False
637  for affected_file in input_api.AffectedFiles():
638    affected_file_path = affected_file.LocalPath()
639    file_path, file_ext = os.path.splitext(affected_file_path)
640    if affected_file_path == RELEASE_NOTES_FILE_NAME:
641      top_relnotes_changed = True
642    elif os.path.dirname(file_path) == RELEASE_NOTES_DIR:
643      release_file_changed = True
644  # When relnotes_util is run it will modify RELEASE_NOTES_FILE_NAME
645  # and delete the individual note files in RELEASE_NOTES_DIR.
646  # So, if both paths are modified do not emit a warning.
647  if top_relnotes_changed and not release_file_changed:
648    results.append(output_api.PresubmitPromptWarning(
649        'Do not edit %s directly. %s is automatically edited during the '
650        'release process. Release notes should be added as new files in '
651        'the %s directory. More information can be found in %s.' % (RELEASE_NOTES_FILE_NAME,
652                                                                    RELEASE_NOTES_FILE_NAME,
653                                                                    RELEASE_NOTES_DIR,
654                                                                    RELEASE_NOTES_README)))
655  return results
656
657
658def PostUploadHook(gerrit, change, output_api):
659  """git cl upload will call this hook after the issue is created/modified.
660
661  This hook does the following:
662  * Adds a link to preview docs changes if there are any docs changes in the CL.
663  * Adds 'No-Try: true' if the CL contains only docs changes.
664  """
665  if not change.issue:
666    return []
667
668  # Skip PostUploadHooks for all auto-commit service account bots. New
669  # patchsets (caused due to PostUploadHooks) invalidates the CQ+2 vote from
670  # the "--use-commit-queue" flag to "git cl upload".
671  for suffix in SERVICE_ACCOUNT_SUFFIX:
672    if change.author_email.endswith(suffix):
673      return []
674
675  results = []
676  at_least_one_docs_change = False
677  all_docs_changes = True
678  for affected_file in change.AffectedFiles():
679    affected_file_path = affected_file.LocalPath()
680    file_path, _ = os.path.splitext(affected_file_path)
681    if 'site' == file_path.split(os.path.sep)[0]:
682      at_least_one_docs_change = True
683    else:
684      all_docs_changes = False
685    if at_least_one_docs_change and not all_docs_changes:
686      break
687
688  footers = change.GitFootersFromDescription()
689  description_changed = False
690
691  # If the change includes only doc changes then add No-Try: true in the
692  # CL's description if it does not exist yet.
693  if all_docs_changes and 'true' not in footers.get('No-Try', []):
694    description_changed = True
695    change.AddDescriptionFooter('No-Try', 'true')
696    results.append(
697        output_api.PresubmitNotifyResult(
698            'This change has only doc changes. Automatically added '
699            '\'No-Try: true\' to the CL\'s description'))
700
701  # If the description has changed update it.
702  if description_changed:
703    gerrit.UpdateDescription(
704        change.FullDescriptionText(), change.issue)
705
706  return results
707
708
709def CheckChangeOnCommit(input_api, output_api):
710  """Presubmit checks for the change on commit."""
711  results = []
712  results.extend(_CommonChecks(input_api, output_api))
713  # Checks for the presence of 'DO NOT''SUBMIT' in CL description and in
714  # content of files.
715  results.extend(
716      input_api.canned_checks.CheckDoNotSubmit(input_api, output_api))
717  return results
718