| # Copyright 2016 The Chromium Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """A command to download new baselines for NeedsRebaseline tests. | |
| This command checks the list of tests with NeedsRebaseline expectations, | |
| and downloads the latest baselines for those tests from the results archived | |
| by the continuous builders. | |
| """ | |
| import logging | |
| import optparse | |
| import re | |
| import sys | |
| import time | |
| import traceback | |
| import urllib2 | |
| from webkitpy.common.net.buildbot import Build, current_build_link | |
| from webkitpy.layout_tests.models.test_expectations import TestExpectations, BASELINE_SUFFIX_LIST | |
| from webkitpy.tool.commands.rebaseline import AbstractParallelRebaselineCommand | |
| _log = logging.getLogger(__name__) | |
| class AutoRebaseline(AbstractParallelRebaselineCommand): | |
| name = 'auto-rebaseline' | |
| help_text = 'Rebaselines any NeedsRebaseline lines in TestExpectations that have cycled through all the bots.' | |
| AUTO_REBASELINE_BRANCH_NAME = 'auto-rebaseline-temporary-branch' | |
| AUTO_REBASELINE_ALT_BRANCH_NAME = 'auto-rebaseline-alt-temporary-branch' | |
| # Rietveld uploader stinks. Limit the number of rebaselines in a given patch to keep upload from failing. | |
| # FIXME: http://crbug.com/263676 Obviously we should fix the uploader here. | |
| MAX_LINES_TO_REBASELINE = 200 | |
| SECONDS_BEFORE_GIVING_UP = 300 | |
| def __init__(self): | |
| super(AutoRebaseline, self).__init__(options=[ | |
| # FIXME: Remove this option. | |
| self.no_optimize_option, | |
| # FIXME: Remove this option. | |
| self.results_directory_option, | |
| optparse.make_option('--auth-refresh-token-json', help='Rietveld auth refresh JSON token.'), | |
| optparse.make_option('--dry-run', action='store_true', default=False, | |
| help='Run without creating a temporary branch, committing locally, or uploading/landing ' | |
| 'changes to the remote repository.') | |
| ]) | |
| self._blame_regex = re.compile(r''' | |
| ^(\S*) # Commit hash | |
| [^(]* \( # Whitespace and open parenthesis | |
| < # Email address is surrounded by <> | |
| ( | |
| [^@]+ # Username preceding @ | |
| @ | |
| [^@>]+ # Domain terminated by @ or >, some lines have an additional @ fragment after the email. | |
| ) | |
| .*?([^ ]*) # Test file name | |
| \ \[ # Single space followed by opening [ for expectation specifier | |
| [^[]*$ # Prevents matching previous [ for version specifiers instead of expectation specifiers | |
| ''', re.VERBOSE) | |
| def bot_revision_data(self, git): | |
| revisions = [] | |
| for builder_name in self._release_builders(): | |
| result = self._tool.buildbot.fetch_results(Build(builder_name)) | |
| if result.run_was_interrupted(): | |
| _log.error("Can't rebaseline because the latest run on %s exited early.", result.builder_name()) | |
| return [] | |
| revisions.append({ | |
| 'builder': result.builder_name(), | |
| 'revision': result.chromium_revision(git), | |
| }) | |
| return revisions | |
| @staticmethod | |
| def _strip_comments(line): | |
| comment_index = line.find('#') | |
| if comment_index == -1: | |
| comment_index = len(line) | |
| return re.sub(r"\s+", ' ', line[:comment_index].strip()) | |
| def tests_to_rebaseline(self, tool, min_revision, print_revisions): | |
| port = tool.port_factory.get() | |
| expectations_file_path = port.path_to_generic_test_expectations_file() | |
| tests = set() | |
| revision = None | |
| commit = None | |
| author = None | |
| bugs = set() | |
| has_any_needs_rebaseline_lines = False | |
| for line in tool.git().blame(expectations_file_path).split('\n'): | |
| line = self._strip_comments(line) | |
| if 'NeedsRebaseline' not in line: | |
| continue | |
| has_any_needs_rebaseline_lines = True | |
| parsed_line = self._blame_regex.match(line) | |
| if not parsed_line: | |
| # Deal gracefully with inability to parse blame info for a line in TestExpectations. | |
| # Parsing could fail if for example during local debugging the developer modifies | |
| # TestExpectations and does not commit. | |
| _log.info("Couldn't find blame info for expectations line, skipping [line=%s].", line) | |
| continue | |
| commit_hash = parsed_line.group(1) | |
| commit_position = tool.git().commit_position_from_git_commit(commit_hash) | |
| test = parsed_line.group(3) | |
| if print_revisions: | |
| _log.info('%s is waiting for r%s', test, commit_position) | |
| if not commit_position or commit_position > min_revision: | |
| continue | |
| if revision and commit_position != revision: | |
| continue | |
| if not revision: | |
| revision = commit_position | |
| commit = commit_hash | |
| author = parsed_line.group(2) | |
| bugs.update(re.findall(r"crbug\.com\/(\d+)", line)) | |
| tests.add(test) | |
| if len(tests) >= self.MAX_LINES_TO_REBASELINE: | |
| _log.info('Too many tests to rebaseline in one patch. Doing the first %d.', self.MAX_LINES_TO_REBASELINE) | |
| break | |
| return tests, revision, commit, author, bugs, has_any_needs_rebaseline_lines | |
| def commit_message(self, author, revision, commit, bugs): | |
| message = 'Auto-rebaseline for r%s\n\n' % revision | |
| build_link = current_build_link(self._tool) | |
| if build_link: | |
| message += 'Build: %s\n\n' % build_link | |
| message += '%s\n\n' % self.link_to_patch(commit) | |
| if bugs: | |
| message += 'BUG=%s\n' % ','.join(bugs) | |
| message += 'TBR=%s\n' % author | |
| return message | |
| @staticmethod | |
| def link_to_patch(commit): | |
| return 'https://chromium.googlesource.com/chromium/src/+/' + commit | |
| def get_test_prefix_list(self, tests): | |
| test_prefix_list = {} | |
| lines_to_remove = {} | |
| for builder_name in self._release_builders(): | |
| port_name = self._tool.builders.port_name_for_builder_name(builder_name) | |
| port = self._tool.port_factory.get(port_name) | |
| expectations = TestExpectations(port, include_overrides=True) | |
| for test in expectations.get_needs_rebaseline_failures(): | |
| if test not in tests: | |
| continue | |
| if test not in test_prefix_list: | |
| lines_to_remove[test] = [] | |
| test_prefix_list[test] = {} | |
| lines_to_remove[test].append(builder_name) | |
| test_prefix_list[test][Build(builder_name)] = BASELINE_SUFFIX_LIST | |
| return test_prefix_list, lines_to_remove | |
| def _run_git_cl_command(self, options, command): | |
| subprocess_command = ['git', 'cl'] + command | |
| if options.verbose: | |
| subprocess_command.append('--verbose') | |
| if options.auth_refresh_token_json: | |
| subprocess_command.append('--auth-refresh-token-json') | |
| subprocess_command.append(options.auth_refresh_token_json) | |
| process = self._tool.executive.popen(subprocess_command, stdout=self._tool.executive.PIPE, | |
| stderr=self._tool.executive.STDOUT) | |
| last_output_time = time.time() | |
| # git cl sometimes completely hangs. Bail if we haven't gotten any output to stdout/stderr in a while. | |
| while process.poll() is None and time.time() < last_output_time + self.SECONDS_BEFORE_GIVING_UP: | |
| # FIXME: This doesn't make any sense. readline blocks, so all this code to | |
| # try and bail is useless. Instead, we should do the readline calls on a | |
| # subthread. Then the rest of this code would make sense. | |
| out = process.stdout.readline().rstrip('\n') | |
| if out: | |
| last_output_time = time.time() | |
| _log.info(out) | |
| if process.poll() is None: | |
| _log.error('Command hung: %s', subprocess_command) | |
| return False | |
| return True | |
| # FIXME: Move this somewhere more general. | |
| @staticmethod | |
| def tree_status(): | |
| blink_tree_status_url = 'http://chromium-status.appspot.com/status' | |
| status = urllib2.urlopen(blink_tree_status_url).read().lower() | |
| if 'closed' in status or status == '0': | |
| return 'closed' | |
| elif 'open' in status or status == '1': | |
| return 'open' | |
| return 'unknown' | |
| def execute(self, options, args, tool): | |
| self._tool = tool | |
| if tool.git().executable_name == 'svn': | |
| _log.error('Auto rebaseline only works with a git checkout.') | |
| return | |
| if not options.dry_run and tool.git().has_working_directory_changes(): | |
| _log.error('Cannot proceed with working directory changes. Clean working directory first.') | |
| return | |
| revision_data = self.bot_revision_data(tool.git()) | |
| if not revision_data: | |
| return | |
| min_revision = int(min([item['revision'] for item in revision_data])) | |
| tests, revision, commit, author, bugs, _ = self.tests_to_rebaseline( | |
| tool, min_revision, print_revisions=options.verbose) | |
| if options.verbose: | |
| _log.info('Min revision across all bots is %s.', min_revision) | |
| for item in revision_data: | |
| _log.info('%s: r%s', item['builder'], item['revision']) | |
| if not tests: | |
| _log.debug('No tests to rebaseline.') | |
| return | |
| if self.tree_status() == 'closed': | |
| _log.info('Cannot proceed. Tree is closed.') | |
| return | |
| _log.info('Rebaselining %s for r%s by %s.', list(tests), revision, author) | |
| test_prefix_list, _ = self.get_test_prefix_list(tests) | |
| did_switch_branches = False | |
| did_finish = False | |
| old_branch_name_or_ref = '' | |
| rebaseline_branch_name = self.AUTO_REBASELINE_BRANCH_NAME | |
| try: | |
| # Save the current branch name and check out a clean branch for the patch. | |
| old_branch_name_or_ref = tool.git().current_branch_or_ref() | |
| if old_branch_name_or_ref == self.AUTO_REBASELINE_BRANCH_NAME: | |
| rebaseline_branch_name = self.AUTO_REBASELINE_ALT_BRANCH_NAME | |
| if not options.dry_run: | |
| tool.git().delete_branch(rebaseline_branch_name) | |
| tool.git().create_clean_branch(rebaseline_branch_name) | |
| did_switch_branches = True | |
| if test_prefix_list: | |
| self.rebaseline(options, test_prefix_list) | |
| if options.dry_run: | |
| return | |
| tool.git().commit_locally_with_message( | |
| self.commit_message(author, revision, commit, bugs)) | |
| # FIXME: It would be nice if we could dcommit the patch without uploading, but still | |
| # go through all the precommit hooks. For rebaselines with lots of files, uploading | |
| # takes a long time and sometimes fails, but we don't want to commit if, e.g. the | |
| # tree is closed. | |
| did_finish = self._run_git_cl_command(options, ['upload', '-f']) | |
| if did_finish: | |
| # Uploading can take a very long time. Do another pull to make sure TestExpectations is up to date, | |
| # so the dcommit can go through. | |
| # FIXME: Log the pull and dcommit stdout/stderr to the log-server. | |
| tool.executive.run_command(['git', 'pull']) | |
| self._run_git_cl_command(options, ['land', '-f', '-v']) | |
| except OSError: | |
| traceback.print_exc(file=sys.stderr) | |
| finally: | |
| if did_switch_branches: | |
| if did_finish: | |
| # Close the issue if dcommit failed. | |
| issue_already_closed = tool.executive.run_command( | |
| ['git', 'config', 'branch.%s.rietveldissue' % rebaseline_branch_name], | |
| return_exit_code=True) | |
| if not issue_already_closed: | |
| self._run_git_cl_command(options, ['set_close']) | |
| tool.git().ensure_cleanly_tracking_remote_master() | |
| if old_branch_name_or_ref: | |
| tool.git().checkout_branch(old_branch_name_or_ref) | |
| tool.git().delete_branch(rebaseline_branch_name) |