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