To prep for removing CMake bots, take them off the CQ and presubmit.
[platform/upstream/libSkiaSharp.git] / PRESUBMIT.py
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 collections
13 import csv
14 import fnmatch
15 import os
16 import re
17 import subprocess
18 import sys
19 import traceback
20
21
22 REVERT_CL_SUBJECT_PREFIX = 'Revert '
23
24 SKIA_TREE_STATUS_URL = 'http://skia-tree-status.appspot.com'
25
26 # Please add the complete email address here (and not just 'xyz@' or 'xyz').
27 PUBLIC_API_OWNERS = (
28     'reed@chromium.org',
29     'reed@google.com',
30     'bsalomon@chromium.org',
31     'bsalomon@google.com',
32     'djsollen@chromium.org',
33     'djsollen@google.com',
34 )
35
36 AUTHORS_FILE_NAME = 'AUTHORS'
37
38 DOCS_PREVIEW_URL = 'https://skia.org/?cl='
39 GOLD_TRYBOT_URL = 'https://gold.skia.org/search?issue='
40
41 # Path to CQ bots feature is described in https://bug.skia.org/4364
42 PATH_PREFIX_TO_EXTRA_TRYBOTS = {
43     # pylint: disable=line-too-long
44     'src/opts/': 'master.client.skia:Test-Ubuntu-GCC-GCE-CPU-AVX2-x86_64-Release-SKNX_NO_SIMD-Trybot',
45
46     'include/private/SkAtomics.h': ('master.client.skia:'
47       'Test-Ubuntu-Clang-GCE-CPU-AVX2-x86_64-Release-TSAN-Trybot,'
48       'Test-Ubuntu-Clang-Golo-GPU-GT610-x86_64-Release-TSAN-Trybot'
49     ),
50
51     # Below are examples to show what is possible with this feature.
52     # 'src/svg/': 'master1:abc;master2:def',
53     # 'src/svg/parser/': 'master3:ghi,jkl;master4:mno',
54     # 'src/image/SkImage_Base.h': 'master5:pqr,stu;master1:abc1;master2:def',
55 }
56
57
58 def _CheckChangeHasEol(input_api, output_api, source_file_filter=None):
59   """Checks that files end with atleast one \n (LF)."""
60   eof_files = []
61   for f in input_api.AffectedSourceFiles(source_file_filter):
62     contents = input_api.ReadFile(f, 'rb')
63     # Check that the file ends in atleast one newline character.
64     if len(contents) > 1 and contents[-1:] != '\n':
65       eof_files.append(f.LocalPath())
66
67   if eof_files:
68     return [output_api.PresubmitPromptWarning(
69       'These files should end in a newline character:',
70       items=eof_files)]
71   return []
72
73
74 def _PythonChecks(input_api, output_api):
75   """Run checks on any modified Python files."""
76   pylint_disabled_warnings = (
77       'F0401',  # Unable to import.
78       'E0611',  # No name in module.
79       'W0232',  # Class has no __init__ method.
80       'E1002',  # Use of super on an old style class.
81       'W0403',  # Relative import used.
82       'R0201',  # Method could be a function.
83       'E1003',  # Using class name in super.
84       'W0613',  # Unused argument.
85       'W0105',  # String statement has no effect.
86   )
87   # Run Pylint on only the modified python files. Unfortunately it still runs
88   # Pylint on the whole file instead of just the modified lines.
89   affected_python_files = []
90   for affected_file in input_api.AffectedSourceFiles(None):
91     affected_file_path = affected_file.LocalPath()
92     if affected_file_path.endswith('.py'):
93       affected_python_files.append(affected_file_path)
94   return input_api.canned_checks.RunPylint(
95       input_api, output_api,
96       disabled_warnings=pylint_disabled_warnings,
97       white_list=affected_python_files)
98
99
100 def _IfDefChecks(input_api, output_api):
101   """Ensures if/ifdef are not before includes. See skbug/3362 for details."""
102   comment_block_start_pattern = re.compile('^\s*\/\*.*$')
103   comment_block_middle_pattern = re.compile('^\s+\*.*')
104   comment_block_end_pattern = re.compile('^\s+\*\/.*$')
105   single_line_comment_pattern = re.compile('^\s*//.*$')
106   def is_comment(line):
107     return (comment_block_start_pattern.match(line) or
108             comment_block_middle_pattern.match(line) or
109             comment_block_end_pattern.match(line) or
110             single_line_comment_pattern.match(line))
111
112   empty_line_pattern = re.compile('^\s*$')
113   def is_empty_line(line):
114     return empty_line_pattern.match(line)
115
116   failing_files = []
117   for affected_file in input_api.AffectedSourceFiles(None):
118     affected_file_path = affected_file.LocalPath()
119     if affected_file_path.endswith('.cpp') or affected_file_path.endswith('.h'):
120       f = open(affected_file_path)
121       for line in f.xreadlines():
122         if is_comment(line) or is_empty_line(line):
123           continue
124         # The below will be the first real line after comments and newlines.
125         if line.startswith('#if 0 '):
126           pass
127         elif line.startswith('#if ') or line.startswith('#ifdef '):
128           failing_files.append(affected_file_path)
129         break
130
131   results = []
132   if failing_files:
133     results.append(
134         output_api.PresubmitError(
135             'The following files have #if or #ifdef before includes:\n%s\n\n'
136             'See https://bug.skia.org/3362 for why this should be fixed.' %
137                 '\n'.join(failing_files)))
138   return results
139
140
141 def _CopyrightChecks(input_api, output_api, source_file_filter=None):
142   results = []
143   year_pattern = r'\d{4}'
144   year_range_pattern = r'%s(-%s)?' % (year_pattern, year_pattern)
145   years_pattern = r'%s(,%s)*,?' % (year_range_pattern, year_range_pattern)
146   copyright_pattern = (
147       r'Copyright (\([cC]\) )?%s \w+' % years_pattern)
148
149   for affected_file in input_api.AffectedSourceFiles(source_file_filter):
150     if 'third_party' in affected_file.LocalPath():
151       continue
152     contents = input_api.ReadFile(affected_file, 'rb')
153     if not re.search(copyright_pattern, contents):
154       results.append(output_api.PresubmitError(
155           '%s is missing a correct copyright header.' % affected_file))
156   return results
157
158
159 def _ToolFlags(input_api, output_api):
160   """Make sure `{dm,nanobench}_flags.py test` passes if modified."""
161   results = []
162   sources = lambda x: ('dm_flags.py'        in x.LocalPath() or
163                        'nanobench_flags.py' in x.LocalPath())
164   for f in input_api.AffectedSourceFiles(sources):
165     if 0 != subprocess.call(['python', f.LocalPath(), 'test']):
166       results.append(output_api.PresubmitError('`python %s test` failed' % f))
167   return results
168
169
170 def _RecipeSimulationTest(input_api, output_api):
171   """Run the recipe simulation test."""
172   results = []
173   if not any(f.LocalPath().startswith('infra')
174              for f in input_api.AffectedFiles()):
175     return results
176
177   recipes_py = os.path.join('infra', 'bots', 'recipes.py')
178   cmd = ['python', recipes_py, 'simulation_test']
179   try:
180     subprocess.check_output(cmd)
181   except subprocess.CalledProcessError as e:
182     results.append(output_api.PresubmitError(
183         '`%s` failed:\n%s' % (' '.join(cmd), e.output)))
184   return results
185
186 def _CheckGNFormatted(input_api, output_api):
187   """Make sure any .gn files we're changing have been formatted."""
188   results = []
189   for f in input_api.AffectedFiles():
190     if not f.LocalPath().endswith('.gn'):
191       continue
192
193     cmd = ['gn', 'format', '--dry-run', f.LocalPath()]
194     try:
195       subprocess.check_output(cmd)
196     except subprocess.CalledProcessError:
197       fix = 'gn format ' + f.LocalPath()
198       results.append(output_api.PresubmitError(
199           '`%s` failed, try\n\t%s' % (' '.join(cmd), fix)))
200   return results
201
202
203 def _CommonChecks(input_api, output_api):
204   """Presubmit checks common to upload and commit."""
205   results = []
206   sources = lambda x: (x.LocalPath().endswith('.h') or
207                        x.LocalPath().endswith('.gypi') or
208                        x.LocalPath().endswith('.gyp') or
209                        x.LocalPath().endswith('.py') or
210                        x.LocalPath().endswith('.sh') or
211                        x.LocalPath().endswith('.m') or
212                        x.LocalPath().endswith('.mm') or
213                        x.LocalPath().endswith('.go') or
214                        x.LocalPath().endswith('.c') or
215                        x.LocalPath().endswith('.cc') or
216                        x.LocalPath().endswith('.cpp'))
217   results.extend(
218       _CheckChangeHasEol(
219           input_api, output_api, source_file_filter=sources))
220   results.extend(_PythonChecks(input_api, output_api))
221   results.extend(_IfDefChecks(input_api, output_api))
222   results.extend(_CopyrightChecks(input_api, output_api,
223                                   source_file_filter=sources))
224   results.extend(_ToolFlags(input_api, output_api))
225   return results
226
227
228 def CheckChangeOnUpload(input_api, output_api):
229   """Presubmit checks for the change on upload.
230
231   The following are the presubmit checks:
232   * Check change has one and only one EOL.
233   """
234   results = []
235   results.extend(_CommonChecks(input_api, output_api))
236   # Run on upload, not commit, since the presubmit bot apparently doesn't have
237   # coverage installed.
238   results.extend(_RecipeSimulationTest(input_api, output_api))
239   results.extend(_CheckGNFormatted(input_api, output_api))
240   return results
241
242
243 def _CheckTreeStatus(input_api, output_api, json_url):
244   """Check whether to allow commit.
245
246   Args:
247     input_api: input related apis.
248     output_api: output related apis.
249     json_url: url to download json style status.
250   """
251   tree_status_results = input_api.canned_checks.CheckTreeIsOpen(
252       input_api, output_api, json_url=json_url)
253   if not tree_status_results:
254     # Check for caution state only if tree is not closed.
255     connection = input_api.urllib2.urlopen(json_url)
256     status = input_api.json.loads(connection.read())
257     connection.close()
258     if ('caution' in status['message'].lower() and
259         os.isatty(sys.stdout.fileno())):
260       # Display a prompt only if we are in an interactive shell. Without this
261       # check the commit queue behaves incorrectly because it considers
262       # prompts to be failures.
263       short_text = 'Tree state is: ' + status['general_state']
264       long_text = status['message'] + '\n' + json_url
265       tree_status_results.append(
266           output_api.PresubmitPromptWarning(
267               message=short_text, long_text=long_text))
268   else:
269     # Tree status is closed. Put in message about contacting sheriff.
270     connection = input_api.urllib2.urlopen(
271         SKIA_TREE_STATUS_URL + '/current-sheriff')
272     sheriff_details = input_api.json.loads(connection.read())
273     if sheriff_details:
274       tree_status_results[0]._message += (
275           '\n\nPlease contact the current Skia sheriff (%s) if you are trying '
276           'to submit a build fix\nand do not know how to submit because the '
277           'tree is closed') % sheriff_details['username']
278   return tree_status_results
279
280
281 class CodeReview(object):
282   """Abstracts which codereview tool is used for the specified issue."""
283
284   def __init__(self, input_api):
285     self._issue = input_api.change.issue
286     self._gerrit = input_api.gerrit
287     self._rietveld_properties = None
288     if not self._gerrit:
289       self._rietveld_properties = input_api.rietveld.get_issue_properties(
290           issue=int(self._issue), messages=True)
291
292   def GetOwnerEmail(self):
293     if self._gerrit:
294       return self._gerrit.GetChangeOwner(self._issue)
295     else:
296       return self._rietveld_properties['owner_email']
297
298   def GetSubject(self):
299     if self._gerrit:
300       return self._gerrit.GetChangeInfo(self._issue)['subject']
301     else:
302       return self._rietveld_properties['subject']
303
304   def GetDescription(self):
305     if self._gerrit:
306       return self._gerrit.GetChangeDescription(self._issue)
307     else:
308       return self._rietveld_properties['description']
309
310   def IsDryRun(self):
311     if self._gerrit:
312       return self._gerrit.GetChangeInfo(
313           self._issue)['labels']['Commit-Queue'].get('value', 0) == 1
314     else:
315       return self._rietveld_properties['cq_dry_run']
316
317   def GetApprovers(self):
318     approvers = []
319     if self._gerrit:
320       for m in self._gerrit.GetChangeInfo(
321                    self._issue)['labels']['Code-Review']['all']:
322         if m.get("value") == 1:
323           approvers.append(m["email"])
324     else:
325       for m in self._rietveld_properties.get('messages', []):
326         if 'lgtm' in m['text'].lower():
327           approvers.append(m['sender'])
328     return approvers
329
330
331 def _CheckOwnerIsInAuthorsFile(input_api, output_api):
332   results = []
333   if input_api.change.issue:
334     cr = CodeReview(input_api)
335
336     owner_email = cr.GetOwnerEmail()
337     try:
338       authors_content = ''
339       for line in open(AUTHORS_FILE_NAME):
340         if not line.startswith('#'):
341           authors_content += line
342       email_fnmatches = re.findall('<(.*)>', authors_content)
343       for email_fnmatch in email_fnmatches:
344         if fnmatch.fnmatch(owner_email, email_fnmatch):
345           # Found a match, the user is in the AUTHORS file break out of the loop
346           break
347       else:
348         results.append(
349           output_api.PresubmitError(
350             'The email %s is not in Skia\'s AUTHORS file.\n'
351             'Issue owner, this CL must include an addition to the Skia AUTHORS '
352             'file.'
353             % owner_email))
354     except IOError:
355       # Do not fail if authors file cannot be found.
356       traceback.print_exc()
357       input_api.logging.error('AUTHORS file not found!')
358
359   return results
360
361
362 def _CheckLGTMsForPublicAPI(input_api, output_api):
363   """Check LGTMs for public API changes.
364
365   For public API files make sure there is an LGTM from the list of owners in
366   PUBLIC_API_OWNERS.
367   """
368   results = []
369   requires_owner_check = False
370   for affected_file in input_api.AffectedFiles():
371     affected_file_path = affected_file.LocalPath()
372     file_path, file_ext = os.path.splitext(affected_file_path)
373     # We only care about files that end in .h and are under the top-level
374     # include dir, but not include/private.
375     if (file_ext == '.h' and
376         'include' == file_path.split(os.path.sep)[0] and
377         'private' not in file_path):
378       requires_owner_check = True
379
380   if not requires_owner_check:
381     return results
382
383   lgtm_from_owner = False
384   if input_api.change.issue:
385     cr = CodeReview(input_api)
386
387     if re.match(REVERT_CL_SUBJECT_PREFIX, cr.GetSubject(), re.I):
388       # It is a revert CL, ignore the public api owners check.
389       return results
390
391     if cr.IsDryRun():
392       # Ignore public api owners check for dry run CLs since they are not
393       # going to be committed.
394       return results
395
396     match = re.search(r'^TBR=(.*)$', cr.GetDescription(), re.M)
397     if match:
398       tbr_entries = match.group(1).strip().split(',')
399       for owner in PUBLIC_API_OWNERS:
400         if owner in tbr_entries or owner.split('@')[0] in tbr_entries:
401           # If an owner is specified in the TBR= line then ignore the public
402           # api owners check.
403           return results
404
405     if cr.GetOwnerEmail() in PUBLIC_API_OWNERS:
406       # An owner created the CL that is an automatic LGTM.
407       lgtm_from_owner = True
408
409     for approver in cr.GetApprovers():
410       if approver in PUBLIC_API_OWNERS:
411         # Found an lgtm in a message from an owner.
412         lgtm_from_owner = True
413         break
414
415   if not lgtm_from_owner:
416     results.append(
417         output_api.PresubmitError(
418             "If this CL adds to or changes Skia's public API, you need an LGTM "
419             "from any of %s.  If this CL only removes from or doesn't change "
420             "Skia's public API, please add a short note to the CL saying so "
421             "and add one of those reviewers on a TBR= line.  If you don't know "
422             "if this CL affects Skia's public API, treat it like it does."
423             % str(PUBLIC_API_OWNERS)))
424   return results
425
426
427 def PostUploadHook(cl, change, output_api):
428   """git cl upload will call this hook after the issue is created/modified.
429
430   This hook does the following:
431   * Adds a link to the CL's Gold trybot results.
432   * Adds a link to preview docs changes if there are any docs changes in the CL.
433   * Adds 'NOTRY=true' if the CL contains only docs changes.
434   * Adds 'NOTREECHECKS=true' for non master branch changes since they do not
435     need to be gated on the master branch's tree.
436   * Adds 'NOTRY=true' for non master branch changes since trybots do not yet
437     work on them.
438   * Adds 'NOPRESUBMIT=true' for non master branch changes since those don't
439     run the presubmit checks.
440   * Adds extra trybots for the paths defined in PATH_TO_EXTRA_TRYBOTS.
441   """
442
443   results = []
444   atleast_one_docs_change = False
445   all_docs_changes = True
446   for affected_file in change.AffectedFiles():
447     affected_file_path = affected_file.LocalPath()
448     file_path, _ = os.path.splitext(affected_file_path)
449     if 'site' == file_path.split(os.path.sep)[0]:
450       atleast_one_docs_change = True
451     else:
452       all_docs_changes = False
453     if atleast_one_docs_change and not all_docs_changes:
454       break
455
456   issue = cl.issue
457   if issue:
458     original_description = cl.GetDescription()
459     changeIdLine = None
460     if cl.IsGerrit():
461       # Remove Change-Id from description and add it back at the end.
462       regex = re.compile(r'^(Change-Id: (\w+))(\n*)\Z', re.M | re.I)
463       changeIdLine = re.search(regex, original_description).group(0)
464       original_description = re.sub(regex, '', original_description)
465       original_description = re.sub('\n+\Z', '\n', original_description)
466
467     new_description = original_description
468
469     # Add GOLD_TRYBOT_URL if it does not exist yet.
470     if not re.search(r'^GOLD_TRYBOT_URL=', new_description, re.M | re.I):
471       new_description += '\nGOLD_TRYBOT_URL= %s%s' % (GOLD_TRYBOT_URL, issue)
472       results.append(
473           output_api.PresubmitNotifyResult(
474               'Added link to Gold trybot runs to the CL\'s description.\n'
475               'Note: Results may take sometime to be populated after trybots '
476               'complete.'))
477
478     # If the change includes only doc changes then add NOTRY=true in the
479     # CL's description if it does not exist yet.
480     if all_docs_changes and not re.search(
481         r'^NOTRY=true$', new_description, re.M | re.I):
482       new_description += '\nNOTRY=true'
483       results.append(
484           output_api.PresubmitNotifyResult(
485               'This change has only doc changes. Automatically added '
486               '\'NOTRY=true\' to the CL\'s description'))
487
488     # If there is atleast one docs change then add preview link in the CL's
489     # description if it does not already exist there.
490     if atleast_one_docs_change and not re.search(
491         r'^DOCS_PREVIEW=.*', new_description, re.M | re.I):
492       # Automatically add a link to where the docs can be previewed.
493       new_description += '\nDOCS_PREVIEW= %s%s' % (DOCS_PREVIEW_URL, issue)
494       results.append(
495           output_api.PresubmitNotifyResult(
496               'Automatically added a link to preview the docs changes to the '
497               'CL\'s description'))
498
499     # If the target ref is not master then add NOTREECHECKS=true and NOTRY=true
500     # to the CL's description if it does not already exist there.
501     target_ref = cl.GetRemoteBranch()[1]
502     if target_ref != 'refs/remotes/origin/master':
503       if not re.search(
504           r'^NOTREECHECKS=true$', new_description, re.M | re.I):
505         new_description += "\nNOTREECHECKS=true"
506         results.append(
507             output_api.PresubmitNotifyResult(
508                 'Branch changes do not need to rely on the master branch\'s '
509                 'tree status. Automatically added \'NOTREECHECKS=true\' to the '
510                 'CL\'s description'))
511       if not re.search(
512           r'^NOTRY=true$', new_description, re.M | re.I):
513         new_description += "\nNOTRY=true"
514         results.append(
515             output_api.PresubmitNotifyResult(
516                 'Trybots do not yet work for non-master branches. '
517                 'Automatically added \'NOTRY=true\' to the CL\'s description'))
518       if not re.search(
519           r'^NOPRESUBMIT=true$', new_description, re.M | re.I):
520         new_description += "\nNOPRESUBMIT=true"
521         results.append(
522             output_api.PresubmitNotifyResult(
523                 'Branch changes do not run the presubmit checks.'))
524
525     # Automatically set CQ_INCLUDE_TRYBOTS if any of the changed files here
526     # begin with the paths of interest.
527     cq_master_to_trybots = collections.defaultdict(set)
528     for affected_file in change.AffectedFiles():
529       affected_file_path = affected_file.LocalPath()
530       for path_prefix, extra_bots in PATH_PREFIX_TO_EXTRA_TRYBOTS.iteritems():
531         if affected_file_path.startswith(path_prefix):
532           results.append(
533               output_api.PresubmitNotifyResult(
534                   'Your CL modifies the path %s.\nAutomatically adding %s to '
535                   'the CL description.' % (affected_file_path, extra_bots)))
536           _MergeCQExtraTrybotsMaps(
537               cq_master_to_trybots, _GetCQExtraTrybotsMap(extra_bots))
538     if cq_master_to_trybots:
539       new_description = _AddCQExtraTrybotsToDesc(
540           cq_master_to_trybots, new_description)
541
542     # If the description has changed update it.
543     if new_description != original_description:
544       if changeIdLine:
545         # The Change-Id line must have two newlines before it.
546         new_description += '\n\n' + changeIdLine
547       cl.UpdateDescription(new_description)
548
549     return results
550
551
552 def _AddCQExtraTrybotsToDesc(cq_master_to_trybots, description):
553   """Adds the specified master and trybots to the CQ_INCLUDE_TRYBOTS keyword.
554
555   If the keyword already exists in the description then it appends to it only
556   if the specified values do not already exist.
557   If the keyword does not exist then it creates a new section in the
558   description.
559   """
560   match = re.search(r'^CQ_INCLUDE_TRYBOTS=(.*)$', description, re.M | re.I)
561   if match:
562     original_trybots_map = _GetCQExtraTrybotsMap(match.group(1))
563     _MergeCQExtraTrybotsMaps(cq_master_to_trybots, original_trybots_map)
564     new_description = description.replace(
565         match.group(0), _GetCQExtraTrybotsStr(cq_master_to_trybots))
566   else:
567     new_description = description + "\n%s" % (
568         _GetCQExtraTrybotsStr(cq_master_to_trybots))
569   return new_description
570
571
572 def _MergeCQExtraTrybotsMaps(dest_map, map_to_be_consumed):
573   """Merges two maps of masters to trybots into one."""
574   for master, trybots in map_to_be_consumed.iteritems():
575     dest_map[master].update(trybots)
576   return dest_map
577
578
579 def _GetCQExtraTrybotsMap(cq_extra_trybots_str):
580   """Parses CQ_INCLUDE_TRYBOTS str and returns a map of masters to trybots."""
581   cq_master_to_trybots = collections.defaultdict(set)
582   for section in cq_extra_trybots_str.split(';'):
583     if section:
584       master, bots = section.split(':')
585       cq_master_to_trybots[master].update(bots.split(','))
586   return cq_master_to_trybots
587
588
589 def _GetCQExtraTrybotsStr(cq_master_to_trybots):
590   """Constructs the CQ_INCLUDE_TRYBOTS str from a map of masters to trybots."""
591   sections = []
592   for master, trybots in cq_master_to_trybots.iteritems():
593     sections.append('%s:%s' % (master, ','.join(trybots)))
594   return 'CQ_INCLUDE_TRYBOTS=%s' % ';'.join(sections)
595
596
597 def CheckChangeOnCommit(input_api, output_api):
598   """Presubmit checks for the change on commit.
599
600   The following are the presubmit checks:
601   * Check change has one and only one EOL.
602   * Ensures that the Skia tree is open in
603     http://skia-tree-status.appspot.com/. Shows a warning if it is in 'Caution'
604     state and an error if it is in 'Closed' state.
605   """
606   results = []
607   results.extend(_CommonChecks(input_api, output_api))
608   results.extend(
609       _CheckTreeStatus(input_api, output_api, json_url=(
610           SKIA_TREE_STATUS_URL + '/banner-status?format=json')))
611   results.extend(_CheckLGTMsForPublicAPI(input_api, output_api))
612   results.extend(_CheckOwnerIsInAuthorsFile(input_api, output_api))
613   return results