blob: 3d5e7d394c56c6342d029bff4e3a7db50c6f20ae [file] [log] [blame]
# 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.
"""Exports Chromium changes to web-platform-tests."""
import argparse
import logging
from blinkpy.common.system.log_utils import configure_logging
from blinkpy.w3c.local_wpt import LocalWPT
from blinkpy.w3c.chromium_exportable_commits import exportable_commits_over_last_n_commits
from blinkpy.w3c.common import (
WPT_GH_URL,
WPT_REVISION_FOOTER,
EXPORT_PR_LABEL,
PROVISIONAL_PR_LABEL,
read_credentials
)
from blinkpy.w3c.gerrit import GerritAPI, GerritCL, GerritError
from blinkpy.w3c.wpt_github import WPTGitHub, MergeError
_log = logging.getLogger(__name__)
class TestExporter(object):
def __init__(self, host):
self.host = host
self.wpt_github = None
self.gerrit = None
self.dry_run = False
self.local_wpt = None
def main(self, argv=None):
"""Creates PRs for in-flight CLs and merges changes that land on master.
Returns:
A boolean: True if success, False if there were any patch failures.
"""
options = self.parse_args(argv)
self.dry_run = options.dry_run
log_level = logging.DEBUG if options.verbose else logging.INFO
configure_logging(logging_level=log_level, include_time=True)
if options.verbose:
# Print out the full output when executive.run_command fails.
self.host.executive.error_output_limit = None
credentials = read_credentials(self.host, options.credentials_json)
if not (credentials.get('GH_USER') and credentials.get('GH_TOKEN')):
_log.error('You must provide your GitHub credentials for this '
'script to work.')
_log.error('See https://chromium.googlesource.com/chromium/src'
'/+/master/docs/testing/web_platform_tests.md'
'#GitHub-credentials for instructions on how to set '
'your credentials up.')
return False
self.wpt_github = self.wpt_github or WPTGitHub(self.host, credentials['GH_USER'], credentials['GH_TOKEN'])
self.gerrit = self.gerrit or GerritAPI(self.host, credentials['GERRIT_USER'], credentials['GERRIT_TOKEN'])
self.local_wpt = self.local_wpt or LocalWPT(self.host, credentials['GH_TOKEN'])
self.local_wpt.fetch()
_log.info('Searching for exportable in-flight CLs.')
# The Gerrit search API is slow and easy to fail, so we wrap it in a try
# statement to continue exporting landed commits when it fails.
try:
open_gerrit_cls = self.gerrit.query_exportable_open_cls()
except GerritError as e:
_log.info('In-flight CLs cannot be exported due to the following error:')
_log.error(str(e))
gerrit_error = True
else:
self.process_gerrit_cls(open_gerrit_cls)
gerrit_error = False
_log.info('Searching for exportable Chromium commits.')
exportable_commits, git_errors = self.get_exportable_commits()
self.process_chromium_commits(exportable_commits)
if git_errors:
_log.info('Attention: The following errors have prevented some commits from being '
'exported:')
for error in git_errors:
_log.error(error)
return not (gerrit_error or git_errors)
def parse_args(self, argv):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'-v', '--verbose', action='store_true',
help='log extra details that may be helpful when debugging')
parser.add_argument(
'--dry-run', action='store_true',
help='See what would be done without actually creating or merging '
'any pull requests.')
parser.add_argument(
'--credentials-json', required=True,
help='A JSON file with an object containing zero or more of the '
'following keys: GH_USER, GH_TOKEN, GERRIT_USER, GERRIT_TOKEN')
return parser.parse_args(argv)
def process_gerrit_cls(self, gerrit_cls):
for cl in gerrit_cls:
self.process_gerrit_cl(cl)
def process_gerrit_cl(self, cl):
_log.info('Found Gerrit in-flight CL: "%s" %s', cl.subject, cl.url)
if not cl.has_review_started:
_log.info('CL review has not started, skipping.')
return
pull_request = self.wpt_github.pr_with_change_id(cl.change_id)
if pull_request:
# If CL already has a corresponding PR, see if we need to update it.
pr_url = '{}pull/{}'.format(WPT_GH_URL, pull_request.number)
_log.info('In-flight PR found: %s', pr_url)
pr_cl_revision = self.wpt_github.extract_metadata(WPT_REVISION_FOOTER + ' ', pull_request.body)
if cl.current_revision_sha == pr_cl_revision:
_log.info('PR revision matches CL revision. Nothing to do here.')
return
_log.info('New revision found, updating PR...')
self.create_or_update_pr_from_inflight_cl(cl, pull_request)
else:
# Create a new PR for the CL if it does not have one.
_log.info('No in-flight PR found for CL. Creating...')
self.create_or_update_pr_from_inflight_cl(cl)
def process_chromium_commits(self, exportable_commits):
for commit in exportable_commits:
self.process_chromium_commit(commit)
def process_chromium_commit(self, commit):
_log.info('Found exportable Chromium commit: %s %s', commit.subject(), commit.sha)
pull_request = self.wpt_github.pr_for_chromium_commit(commit)
if pull_request:
pr_url = '{}pull/{}'.format(WPT_GH_URL, pull_request.number)
_log.info('In-flight PR found: %s', pr_url)
if pull_request.state != 'open':
_log.info('Pull request is %s. Skipping.', pull_request.state)
return
if PROVISIONAL_PR_LABEL in pull_request.labels:
# If the PR was created from a Gerrit in-flight CL, update the
# PR with the final checked-in commit in Chromium history.
# TODO(robertma): Only update the PR when it is not up-to-date
# to avoid unnecessary Travis runs.
_log.info('Updating PR with the final checked-in change...')
self.create_or_update_pr_from_landed_commit(commit, pull_request)
self.remove_provisional_pr_label(pull_request)
# Updating the patch triggers Travis, which will block merge.
# Return early and merge next time.
return
self.merge_pull_request(pull_request)
else:
_log.info('No PR found for Chromium commit. Creating...')
self.create_or_update_pr_from_landed_commit(commit)
def get_exportable_commits(self):
"""Gets exportable commits that can apply cleanly and independently.
Returns:
A list of ChromiumCommit for clean exportable commits, and a list
of error messages for other exportable commits that fail to apply.
"""
# Exportable commits that cannot apply cleanly are logged, and will be
# retried next time. A common case is that a commit depends on an
# earlier commit, and can only be exported after the earlier one.
return exportable_commits_over_last_n_commits(
self.host, self.local_wpt, self.wpt_github, require_clean=True)
def remove_provisional_pr_label(self, pull_request):
if self.dry_run:
_log.info('[dry_run] Would have attempted to remove the provisional PR label')
return
_log.info('Removing provisional label "%s"...', PROVISIONAL_PR_LABEL)
self.wpt_github.remove_label(pull_request.number, PROVISIONAL_PR_LABEL)
def merge_pull_request(self, pull_request):
if self.dry_run:
_log.info('[dry_run] Would have attempted to merge PR')
return
_log.info('Attempting to merge...')
# This is outside of the try block because if there's a problem communicating
# with the GitHub API, we should hard fail.
branch = self.wpt_github.get_pr_branch(pull_request.number)
try:
self.wpt_github.merge_pr(pull_request.number)
# This is in the try block because if a PR can't be merged, we shouldn't
# delete its branch.
_log.info('Deleting remote branch %s...', branch)
self.wpt_github.delete_remote_branch(branch)
change_id = self.wpt_github.extract_metadata('Change-Id: ', pull_request.body)
if change_id:
cl = GerritCL(data={'change_id': change_id}, api=self.gerrit)
pr_url = '{}pull/{}'.format(WPT_GH_URL, pull_request.number)
cl.post_comment((
'The WPT PR for this CL has been merged upstream! {pr_url}'
).format(
pr_url=pr_url
))
except MergeError:
_log.warn('Could not merge PR.')
def create_or_update_pr_from_landed_commit(self, commit, pull_request=None):
"""Creates or updates a PR from a landed Chromium commit.
Args:
commit: A ChromiumCommit object.
pull_request: Optional, a PullRequest namedtuple.
If specified, updates the PR instead of creating one.
"""
if pull_request:
self.create_or_update_pr_from_commit(commit, provisional=False, pr_number=pull_request.number)
else:
branch_name = 'chromium-export-' + commit.short_sha
self.create_or_update_pr_from_commit(commit, provisional=False, pr_branch_name=branch_name)
def create_or_update_pr_from_inflight_cl(self, cl, pull_request=None):
"""Creates or updates a PR from an in-flight Gerrit CL.
Args:
cl: A GerritCL object.
pull_request: Optional, a PullRequest namedtuple.
If specified, updates the PR instead of creating one.
"""
commit = cl.fetch_current_revision_commit(self.host)
patch = commit.format_patch()
success, error = self.local_wpt.test_patch(patch)
if not success:
_log.error('Gerrit CL patch did not apply cleanly:')
_log.error(error)
_log.debug('First 500 characters of patch: << END_OF_PATCH_EXCERPT')
_log.debug(patch[0:500])
_log.debug('END_OF_PATCH_EXCERPT')
return
# Reviewed-on footer is not in the git commit message of in-flight CLs,
# but a link to code review is useful so we add it manually.
footer = 'Reviewed-on: {}\n'.format(cl.url)
# WPT_REVISION_FOOTER is used by the exporter to check the CL revision.
footer += '{} {}'.format(WPT_REVISION_FOOTER, cl.current_revision_sha)
if pull_request:
self.create_or_update_pr_from_commit(
commit, provisional=True, pr_number=pull_request.number, pr_footer=footer)
# TODO(jeffcarp): Turn PullRequest into a class with a .url method
cl.post_comment((
'Successfully updated WPT GitHub pull request with '
'new revision "{subject}": {pr_url}'
).format(
subject=cl.current_revision_description,
pr_url='%spull/%d' % (WPT_GH_URL, pull_request.number),
))
else:
branch_name = 'chromium-export-cl-{}'.format(cl.number)
pr_number = self.create_or_update_pr_from_commit(
commit, provisional=True, pr_footer=footer, pr_branch_name=branch_name)
cl.post_comment((
'Exportable changes to web-platform-tests were detected in this CL '
'and a pull request in the upstream repo has been made: {pr_url}.\n\n'
'If this CL lands and Travis CI upstream is green, we will auto-merge the PR.\n\n'
'Note: Please check the Travis CI status (at the bottom of the PR) '
'before landing this CL and only land this CL if the status is green. '
'Otherwise a human needs to step in and resolve it manually. '
'(This may be automated in the future, see https://crbug.com/711447)\n\n'
'WPT Export docs:\n'
'https://chromium.googlesource.com/chromium/src/+/master'
'/docs/testing/web_platform_tests.md#Automatic-export-process'
).format(
pr_url='%spull/%d' % (WPT_GH_URL, pr_number)
))
def create_or_update_pr_from_commit(self, commit, provisional, pr_number=None, pr_footer='', pr_branch_name=None):
"""Creates or updates a PR from a Chromium commit.
The commit can be either landed or in-flight. The exportable portion of
the patch is extracted and applied to a new branch in the local WPT
repo, whose name is determined by pr_branch_name (if the branch already
exists, it will be recreated from master). The branch is then pushed to
WPT on GitHub, from which a PR is created or updated.
Args:
commit: A ChromiumCommit object.
provisional: True if the commit is from a Gerrit in-flight CL,
False if the commit has landed.
pr_number: Optional, a PR issue number.
If specified, updates the PR instead of creating one.
pr_footer: Optional, additional text to be appended to PR
description after the commit message.
pr_branch_name: Optional, the name of the head branch of the PR.
If unspecified, the current head branch of the PR will be used.
"""
patch = commit.format_patch()
message = commit.message()
subject = commit.subject()
body = commit.body()
author = commit.author()
updating = bool(pr_number)
pr_description = body + pr_footer
if not pr_branch_name:
assert pr_number, 'pr_number and pr_branch_name cannot be both absent.'
pr_branch_name = self.wpt_github.get_pr_branch(pr_number)
if self.dry_run:
action_str = 'updating' if updating else 'creating'
origin_str = 'CL' if provisional else 'Chromium commit'
_log.info('[dry_run] Stopping before %s PR from %s', action_str, origin_str)
_log.info('\n\n[dry_run] message:')
_log.info(message)
_log.debug('\n[dry_run] First 500 characters of patch: << END_OF_PATCH_EXCERPT')
_log.debug(patch[0:500])
_log.debug('END_OF_PATCH_EXCERPT')
return
self.local_wpt.create_branch_with_patch(pr_branch_name, message, patch, author, force_push=updating)
if updating:
self.wpt_github.update_pr(pr_number, subject, pr_description)
else:
pr_number = self.wpt_github.create_pr(pr_branch_name, subject, pr_description)
self.wpt_github.add_label(pr_number, EXPORT_PR_LABEL)
if provisional:
self.wpt_github.add_label(pr_number, PROVISIONAL_PR_LABEL)
return pr_number