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