Comments Style: s/skbug.com/bug.skia.org/
[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
40 # Path to CQ bots feature is described in https://bug.skia.org/4364
41 PATH_PREFIX_TO_EXTRA_TRYBOTS = {
42     # pylint: disable=line-too-long
43     'cmake/': 'client.skia.compile:Build-Mac10.9-Clang-x86_64-Release-CMake-Trybot,Build-Ubuntu-GCC-x86_64-Release-CMake-Trybot',
44     # pylint: disable=line-too-long
45     'src/opts/': 'client.skia:Test-Ubuntu-GCC-GCE-CPU-AVX2-x86_64-Release-SKNX_NO_SIMD-Trybot',
46
47     'include/private/SkAtomics.h': ('client.skia:'
48       'Test-Ubuntu-GCC-GCE-CPU-AVX2-x86_64-Release-TSAN-Trybot,'
49       'Test-Ubuntu-GCC-Golo-GPU-GT610-x86_64-Release-TSAN-Trybot'
50     ),
51
52     # Below are examples to show what is possible with this feature.
53     # 'src/svg/': 'master1:abc;master2:def',
54     # 'src/svg/parser/': 'master3:ghi,jkl;master4:mno',
55     # 'src/image/SkImage_Base.h': 'master5:pqr,stu;master1:abc1;master2:def',
56 }
57
58
59 def _CheckChangeHasEol(input_api, output_api, source_file_filter=None):
60   """Checks that files end with atleast one \n (LF)."""
61   eof_files = []
62   for f in input_api.AffectedSourceFiles(source_file_filter):
63     contents = input_api.ReadFile(f, 'rb')
64     # Check that the file ends in atleast one newline character.
65     if len(contents) > 1 and contents[-1:] != '\n':
66       eof_files.append(f.LocalPath())
67
68   if eof_files:
69     return [output_api.PresubmitPromptWarning(
70       'These files should end in a newline character:',
71       items=eof_files)]
72   return []
73
74
75 def _PythonChecks(input_api, output_api):
76   """Run checks on any modified Python files."""
77   pylint_disabled_warnings = (
78       'F0401',  # Unable to import.
79       'E0611',  # No name in module.
80       'W0232',  # Class has no __init__ method.
81       'E1002',  # Use of super on an old style class.
82       'W0403',  # Relative import used.
83       'R0201',  # Method could be a function.
84       'E1003',  # Using class name in super.
85       'W0613',  # Unused argument.
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 _CommonChecks(input_api, output_api):
171   """Presubmit checks common to upload and commit."""
172   results = []
173   sources = lambda x: (x.LocalPath().endswith('.h') or
174                        x.LocalPath().endswith('.gypi') or
175                        x.LocalPath().endswith('.gyp') or
176                        x.LocalPath().endswith('.py') or
177                        x.LocalPath().endswith('.sh') or
178                        x.LocalPath().endswith('.m') or
179                        x.LocalPath().endswith('.mm') or
180                        x.LocalPath().endswith('.go') or
181                        x.LocalPath().endswith('.c') or
182                        x.LocalPath().endswith('.cc') or
183                        x.LocalPath().endswith('.cpp'))
184   results.extend(
185       _CheckChangeHasEol(
186           input_api, output_api, source_file_filter=sources))
187   results.extend(_PythonChecks(input_api, output_api))
188   results.extend(_IfDefChecks(input_api, output_api))
189   results.extend(_CopyrightChecks(input_api, output_api,
190                                   source_file_filter=sources))
191   results.extend(_ToolFlags(input_api, output_api))
192   return results
193
194
195 def CheckChangeOnUpload(input_api, output_api):
196   """Presubmit checks for the change on upload.
197
198   The following are the presubmit checks:
199   * Check change has one and only one EOL.
200   """
201   results = []
202   results.extend(_CommonChecks(input_api, output_api))
203   return results
204
205
206 def _CheckTreeStatus(input_api, output_api, json_url):
207   """Check whether to allow commit.
208
209   Args:
210     input_api: input related apis.
211     output_api: output related apis.
212     json_url: url to download json style status.
213   """
214   tree_status_results = input_api.canned_checks.CheckTreeIsOpen(
215       input_api, output_api, json_url=json_url)
216   if not tree_status_results:
217     # Check for caution state only if tree is not closed.
218     connection = input_api.urllib2.urlopen(json_url)
219     status = input_api.json.loads(connection.read())
220     connection.close()
221     if ('caution' in status['message'].lower() and
222         os.isatty(sys.stdout.fileno())):
223       # Display a prompt only if we are in an interactive shell. Without this
224       # check the commit queue behaves incorrectly because it considers
225       # prompts to be failures.
226       short_text = 'Tree state is: ' + status['general_state']
227       long_text = status['message'] + '\n' + json_url
228       tree_status_results.append(
229           output_api.PresubmitPromptWarning(
230               message=short_text, long_text=long_text))
231   else:
232     # Tree status is closed. Put in message about contacting sheriff.
233     connection = input_api.urllib2.urlopen(
234         SKIA_TREE_STATUS_URL + '/current-sheriff')
235     sheriff_details = input_api.json.loads(connection.read())
236     if sheriff_details:
237       tree_status_results[0]._message += (
238           '\n\nPlease contact the current Skia sheriff (%s) if you are trying '
239           'to submit a build fix\nand do not know how to submit because the '
240           'tree is closed') % sheriff_details['username']
241   return tree_status_results
242
243
244 def _CheckOwnerIsInAuthorsFile(input_api, output_api):
245   results = []
246   issue = input_api.change.issue
247   if issue and input_api.rietveld:
248     issue_properties = input_api.rietveld.get_issue_properties(
249         issue=int(issue), messages=False)
250     owner_email = issue_properties['owner_email']
251
252     try:
253       authors_content = ''
254       for line in open(AUTHORS_FILE_NAME):
255         if not line.startswith('#'):
256           authors_content += line
257       email_fnmatches = re.findall('<(.*)>', authors_content)
258       for email_fnmatch in email_fnmatches:
259         if fnmatch.fnmatch(owner_email, email_fnmatch):
260           # Found a match, the user is in the AUTHORS file break out of the loop
261           break
262       else:
263         results.append(
264           output_api.PresubmitError(
265             'The email %s is not in Skia\'s AUTHORS file.\n'
266             'Issue owner, this CL must include an addition to the Skia AUTHORS '
267             'file.'
268             % owner_email))
269     except IOError:
270       # Do not fail if authors file cannot be found.
271       traceback.print_exc()
272       input_api.logging.error('AUTHORS file not found!')
273
274   return results
275
276
277 def _CheckLGTMsForPublicAPI(input_api, output_api):
278   """Check LGTMs for public API changes.
279
280   For public API files make sure there is an LGTM from the list of owners in
281   PUBLIC_API_OWNERS.
282   """
283   results = []
284   requires_owner_check = False
285   for affected_file in input_api.AffectedFiles():
286     affected_file_path = affected_file.LocalPath()
287     file_path, file_ext = os.path.splitext(affected_file_path)
288     # We only care about files that end in .h and are under the top-level
289     # include dir, but not include/private.
290     if (file_ext == '.h' and
291         'include' == file_path.split(os.path.sep)[0] and
292         'private' not in file_path):
293       requires_owner_check = True
294
295   if not requires_owner_check:
296     return results
297
298   lgtm_from_owner = False
299   issue = input_api.change.issue
300   if issue and input_api.rietveld:
301     issue_properties = input_api.rietveld.get_issue_properties(
302         issue=int(issue), messages=True)
303     if re.match(REVERT_CL_SUBJECT_PREFIX, issue_properties['subject'], re.I):
304       # It is a revert CL, ignore the public api owners check.
305       return results
306
307     if issue_properties['cq_dry_run']:
308       # Ignore public api owners check for dry run CLs since they are not
309       # going to be committed.
310       return results
311
312     match = re.search(r'^TBR=(.*)$', issue_properties['description'], re.M)
313     if match:
314       tbr_entries = match.group(1).strip().split(',')
315       for owner in PUBLIC_API_OWNERS:
316         if owner in tbr_entries or owner.split('@')[0] in tbr_entries:
317           # If an owner is specified in the TBR= line then ignore the public
318           # api owners check.
319           return results
320
321     if issue_properties['owner_email'] in PUBLIC_API_OWNERS:
322       # An owner created the CL that is an automatic LGTM.
323       lgtm_from_owner = True
324
325     messages = issue_properties.get('messages')
326     if messages:
327       for message in messages:
328         if (message['sender'] in PUBLIC_API_OWNERS and
329             'lgtm' in message['text'].lower()):
330           # Found an lgtm in a message from an owner.
331           lgtm_from_owner = True
332           break
333
334   if not lgtm_from_owner:
335     results.append(
336         output_api.PresubmitError(
337             "If this CL adds to or changes Skia's public API, you need an LGTM "
338             "from any of %s.  If this CL only removes from or doesn't change "
339             "Skia's public API, please add a short note to the CL saying so "
340             "and add one of those reviewers on a TBR= line.  If you don't know "
341             "if this CL affects Skia's public API, treat it like it does."
342             % str(PUBLIC_API_OWNERS)))
343   return results
344
345
346 def PostUploadHook(cl, change, output_api):
347   """git cl upload will call this hook after the issue is created/modified.
348
349   This hook does the following:
350   * Adds a link to preview docs changes if there are any docs changes in the CL.
351   * Adds 'NOTRY=true' if the CL contains only docs changes.
352   * Adds 'NOTREECHECKS=true' for non master branch changes since they do not
353     need to be gated on the master branch's tree.
354   * Adds 'NOTRY=true' for non master branch changes since trybots do not yet
355     work on them.
356   * Adds 'NOPRESUBMIT=true' for non master branch changes since those don't
357     run the presubmit checks.
358   * Adds extra trybots for the paths defined in PATH_TO_EXTRA_TRYBOTS.
359   """
360
361   results = []
362   atleast_one_docs_change = False
363   all_docs_changes = True
364   for affected_file in change.AffectedFiles():
365     affected_file_path = affected_file.LocalPath()
366     file_path, _ = os.path.splitext(affected_file_path)
367     if 'site' == file_path.split(os.path.sep)[0]:
368       atleast_one_docs_change = True
369     else:
370       all_docs_changes = False
371     if atleast_one_docs_change and not all_docs_changes:
372       break
373
374   issue = cl.issue
375   rietveld_obj = cl.RpcServer()
376   if issue and rietveld_obj:
377     original_description = rietveld_obj.get_description(issue)
378     new_description = original_description
379
380     # If the change includes only doc changes then add NOTRY=true in the
381     # CL's description if it does not exist yet.
382     if all_docs_changes and not re.search(
383         r'^NOTRY=true$', new_description, re.M | re.I):
384       new_description += '\nNOTRY=true'
385       results.append(
386           output_api.PresubmitNotifyResult(
387               'This change has only doc changes. Automatically added '
388               '\'NOTRY=true\' to the CL\'s description'))
389
390     # If there is atleast one docs change then add preview link in the CL's
391     # description if it does not already exist there.
392     if atleast_one_docs_change and not re.search(
393         r'^DOCS_PREVIEW=.*', new_description, re.M | re.I):
394       # Automatically add a link to where the docs can be previewed.
395       new_description += '\nDOCS_PREVIEW= %s%s' % (DOCS_PREVIEW_URL, issue)
396       results.append(
397           output_api.PresubmitNotifyResult(
398               'Automatically added a link to preview the docs changes to the '
399               'CL\'s description'))
400
401     # If the target ref is not master then add NOTREECHECKS=true and NOTRY=true
402     # to the CL's description if it does not already exist there.
403     target_ref = rietveld_obj.get_issue_properties(issue, False).get(
404         'target_ref', '')
405     if target_ref != 'refs/heads/master':
406       if not re.search(
407           r'^NOTREECHECKS=true$', new_description, re.M | re.I):
408         new_description += "\nNOTREECHECKS=true"
409         results.append(
410             output_api.PresubmitNotifyResult(
411                 'Branch changes do not need to rely on the master branch\'s '
412                 'tree status. Automatically added \'NOTREECHECKS=true\' to the '
413                 'CL\'s description'))
414       if not re.search(
415           r'^NOTRY=true$', new_description, re.M | re.I):
416         new_description += "\nNOTRY=true"
417         results.append(
418             output_api.PresubmitNotifyResult(
419                 'Trybots do not yet work for non-master branches. '
420                 'Automatically added \'NOTRY=true\' to the CL\'s description'))
421       if not re.search(
422           r'^NOPRESUBMIT=true$', new_description, re.M | re.I):
423         new_description += "\nNOPRESUBMIT=true"
424         results.append(
425             output_api.PresubmitNotifyResult(
426                 'Branch changes do not run the presubmit checks.'))
427
428     # Automatically set CQ_EXTRA_TRYBOTS if any of the changed files here begin
429     # with the paths of interest.
430     cq_master_to_trybots = collections.defaultdict(set)
431     for affected_file in change.AffectedFiles():
432       affected_file_path = affected_file.LocalPath()
433       for path_prefix, extra_bots in PATH_PREFIX_TO_EXTRA_TRYBOTS.iteritems():
434         if affected_file_path.startswith(path_prefix):
435           results.append(
436               output_api.PresubmitNotifyResult(
437                   'Your CL modifies the path %s.\nAutomatically adding %s to '
438                   'the CL description.' % (affected_file_path, extra_bots)))
439           _MergeCQExtraTrybotsMaps(
440               cq_master_to_trybots, _GetCQExtraTrybotsMap(extra_bots))
441     if cq_master_to_trybots:
442       new_description = _AddCQExtraTrybotsToDesc(
443           cq_master_to_trybots, new_description)
444
445     # If the description has changed update it.
446     if new_description != original_description:
447       rietveld_obj.update_description(issue, new_description)
448
449     return results
450
451
452 def _AddCQExtraTrybotsToDesc(cq_master_to_trybots, description):
453   """Adds the specified master and trybots to the CQ_EXTRA_TRYBOTS keyword.
454
455   If the keyword already exists in the description then it appends to it only
456   if the specified values do not already exist.
457   If the keyword does not exist then it creates a new section in the
458   description.
459   """
460   match = re.search(r'^CQ_EXTRA_TRYBOTS=(.*)$', description, re.M | re.I)
461   if match:
462     original_trybots_map = _GetCQExtraTrybotsMap(match.group(1))
463     _MergeCQExtraTrybotsMaps(cq_master_to_trybots, original_trybots_map)
464     new_description = description.replace(
465         match.group(0), _GetCQExtraTrybotsStr(cq_master_to_trybots))
466   else:
467     new_description = description + "\n%s" % (
468         _GetCQExtraTrybotsStr(cq_master_to_trybots))
469   return new_description
470
471
472 def _MergeCQExtraTrybotsMaps(dest_map, map_to_be_consumed):
473   """Merges two maps of masters to trybots into one."""
474   for master, trybots in map_to_be_consumed.iteritems():
475     dest_map[master].update(trybots)
476   return dest_map
477
478
479 def _GetCQExtraTrybotsMap(cq_extra_trybots_str):
480   """Parses the CQ_EXTRA_TRYBOTS str and returns a map of masters to trybots."""
481   cq_master_to_trybots = collections.defaultdict(set)
482   for section in cq_extra_trybots_str.split(';'):
483     if section:
484       master, bots = section.split(':')
485       cq_master_to_trybots[master].update(bots.split(','))
486   return cq_master_to_trybots
487
488
489 def _GetCQExtraTrybotsStr(cq_master_to_trybots):
490   """Constructs the CQ_EXTRA_TRYBOTS str from a map of masters to trybots."""
491   sections = []
492   for master, trybots in cq_master_to_trybots.iteritems():
493     sections.append('%s:%s' % (master, ','.join(trybots)))
494   return 'CQ_EXTRA_TRYBOTS=%s' % ';'.join(sections)
495
496
497 def CheckChangeOnCommit(input_api, output_api):
498   """Presubmit checks for the change on commit.
499
500   The following are the presubmit checks:
501   * Check change has one and only one EOL.
502   * Ensures that the Skia tree is open in
503     http://skia-tree-status.appspot.com/. Shows a warning if it is in 'Caution'
504     state and an error if it is in 'Closed' state.
505   """
506   results = []
507   results.extend(_CommonChecks(input_api, output_api))
508   results.extend(
509       _CheckTreeStatus(input_api, output_api, json_url=(
510           SKIA_TREE_STATUS_URL + '/banner-status?format=json')))
511   results.extend(_CheckLGTMsForPublicAPI(input_api, output_api))
512   results.extend(_CheckOwnerIsInAuthorsFile(input_api, output_api))
513   return results