blob: a21ee8cb0fb25f6576f5d8e3789cf6bc2cfe2163 [file] [log] [blame]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
This script translates reviews on ReviewBoard into commits
on the current branch.
"""
import argparse
import atexit
import json
import linecache
import os
import pipes
import platform
import re
import ssl
import subprocess
import sys
import urllib.request
import urllib.error
import urllib.parse
REVIEWBOARD_REVIEW_URL = 'https://reviews.apache.org/r'
REVIEWBOARD_API_URL =\
'https://reviews.apache.org/api/review-requests'
REVIEWBOARD_USER_URL = 'https://reviews.apache.org/api/users'
GITHUB_URL = 'https://api.github.com/repos/apache/mesos/pulls'
GITHUB_PATCH_URL =\
'https://patch-diff.githubusercontent.com/raw/apache/mesos/pull'
def review_api_url(review_id):
"""Returns a Review Board API URL given a review ID."""
# Reviewboard REST API expects '/' at the end of the URL.
return '{base}/{review}/'.format(
base=REVIEWBOARD_API_URL,
review=review_id)
def review_url(review_id):
"""Returns a Review Board UI URL given a review ID."""
return '{base}/{review}/'.format(
base=REVIEWBOARD_REVIEW_URL,
review=review_id)
def pull_request_url(pull_request_number):
"""Returns a GitHub pull request URL given a PR number."""
return '{base}/{pr}'.format(
base=GITHUB_URL,
pr=pull_request_number)
def reviewboard_user_url(username):
"""Returns a Review Board URL for a user given a username."""
# Reviewboard REST API expects '/' at the end of the URL.
return '{base}/{user}/'.format(
base=REVIEWBOARD_USER_URL,
user=username)
def patch_url(options):
"""Returns a Review Board or a GitHub URL for a patch."""
if options['review_id']:
# Reviewboard REST API expects '/' at the end of the URL.
return '{base}/{review}/diff/raw/'.format(
base=REVIEWBOARD_REVIEW_URL,
review=options['review_id'])
if options['github']:
return '{base}/{patch}.patch'.format(
base=GITHUB_PATCH_URL,
patch=options['github'])
return None
def url_to_json(url):
"""Performs HTTP request and returns JSON-ified response."""
json_str = urllib.request.urlopen(url).read().decode('utf-8')
return json.loads(json_str)
def extract_review_id(url):
"""Extracts review ID from Review Board URL."""
review_id = re.search(REVIEWBOARD_API_URL + r'/(\d+)/', url)
if review_id:
return review_id.group(1)
return ''
def review_chain(review_id):
"""Returns a parent review chain for a given review ID."""
json_obj = url_to_json(review_api_url(review_id))
# Stop as soon as we stumble upon a submitted request.
status = json_obj.get('review_request').get('status')
if status == "submitted":
sys.stderr.write('Warning: Review {review} has already'
' been submitted and did not get applied'
' to you current work-tree\n'.format(review=review_id))
return []
# Verify that the review has exactly one parent.
parent = json_obj.get('review_request').get('depends_on')
if len(parent) > 1:
sys.stderr.write('Error: Review {review} has more than'
' one parent'.format(review=review_id))
sys.exit(1)
elif not parent:
return [(review_id, json_obj.get('review_request').get('summary'))]
else:
# The review has exactly one parent.
review_list = review_chain(extract_review_id(parent[0].get('href')))
review = (review_id, json_obj.get('review_request').get('summary'))
if review not in review_list:
return review_list + [review]
sys.stderr.write('Found a circular dependency in the chain starting'
' at {review}\n'.format(review=review_id))
sys.exit(1)
def shell(command, dry_run):
"""
Runs a command in a shell, unless the dry-run option
is set (in which case it just prints the command).
"""
if dry_run:
print(command)
return
error_code = subprocess.call(command, stderr=subprocess.STDOUT, shell=True)
if error_code != 0:
sys.exit(error_code)
def apply_review(options):
"""Applies a review with a given ID locally."""
# Make sure we don't leave the patch behind in case of failure.
# We store the patch ID in a local variable to ensure the lambda
# captures the current patch ID.
patch_file = '%s.patch' % patch_id(options)
if not options["keep_patches"]:
atexit.register(
lambda: os.path.exists(patch_file) and os.remove(patch_file))
fetch_patch(options)
apply_patch(options)
commit_patch(options)
def ssl_create_default_context():
"""
Equivalent to `ssl.create_default_context` with default arguments and
certificate/hostname verification disabled.
See: https://github.com/python/cpython/blob/2.7/Lib/ssl.py#L410
This function requires Python >= 2.7.9.
"""
# pylint: disable=no-member
context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
# SSLv2 considered harmful.
context.options |= ssl.OP_NO_SSLv2
# SSLv3 has problematic security and is only required for really old
# clients such as IE6 on Windows XP.
context.options |= ssl.OP_NO_SSLv3
# Disable compression to prevent CRIME attacks (OpenSSL 1.0+).
context.options |= getattr(ssl, "OP_NO_COMPRESSION", 0)
# Disable certificate and hostname verification.
context.verify_mode = ssl.CERT_NONE
context.check_hostname = False
def fetch_patch(options):
"""Fetches a patch from Review Board or GitHub."""
# pylint: disable=unexpected-keyword-arg
if platform.system() == 'Windows':
# This call requires Python >= 2.7.9.
response = urllib.request.urlopen(
patch_url(options),
context=ssl_create_default_context())
with open('%s.patch' % patch_id(options), 'wb') as patch:
patch.write(response.read())
else:
# NOTE: SSL contexts are only supported in Python 2.7.9+. The version
# of Python running on the non-Windows ASF CI machines is sometimes
# older. Hence, we fall back to `wget` on non-Windows machines.
cmd = ' '.join([
'wget',
'--no-check-certificate',
'--no-verbose',
'-O '
'{review_id}.patch',
'{url}']).format(
review_id=patch_id(options),
url=patch_url(options))
# In case of GitHub we always need to fetch the patch to extract
# username and email, so we ignore the dry_run option by setting the
# second parameter to False.
if options['github']:
shell(cmd, False)
else:
shell(cmd, options['dry_run'])
def patch_id(options):
"""Returns the review ID or the GitHub pull request number."""
return options['review_id'] or options['github']
def apply_patch(options):
"""Applies patch locally."""
cmd = 'git apply --index {review_id}.patch'.format(
review_id=patch_id(options))
if options['3way']:
cmd += ' --3way'
if platform.system() == 'Windows':
# NOTE: Depending on the Git settings, there may or may not be
# carriage returns in files and in the downloaded patch.
# We ignore these errors on Windows.
cmd += ' --ignore-whitespace'
shell(cmd, options['dry_run'])
def quote(string):
"""Quote a variable so it can be safely used in shell."""
return string.replace("'", "'\\''")
def commit_patch(options):
"""Commits patch locally."""
data = patch_data(options)
# Check whether we need to amend the commit message.
if options['no_amend']:
amend = ''
else:
amend = '-e'
# Check whether we should skip the commit hooks.
if options['skip_hooks']:
verify = '-n'
else:
verify = ''
# NOTE: Windows does not support multi-line commit messages via the shell.
message_file = '%s.message' % patch_id(options)
atexit.register(
lambda: os.path.exists(message_file) and os.remove(message_file))
with open(message_file, 'w') as message:
# Add a shell command creating the message file for dry-run mode.
if options["dry_run"]:
shell(
"printf {msg} > {file}".format(
msg=pipes.quote(data['message']).replace('\n', '\\n'),
file=message_file),
True)
message.write(data['message'])
cmd = 'git commit' \
' --author \"{author}\"' \
' {amend} -aF \"{message}\"' \
' {verify}'.format(
author=quote(data['author']),
amend=amend,
message=message_file,
verify=verify)
shell(cmd, options['dry_run'])
def patch_data(options):
"""
Populates and returns a dictionary with data necessary for
committing the patch (such as the message, the author, etc.).
"""
if options['review_id']:
return reviewboard_data(options)
if options['github']:
return github_data(options)
return None
def get_author(patch):
"""Reads the author name and email from the .patch file"""
author = linecache.getline(patch, 2)
return author.replace('From: ', '').rstrip()
def github_data(options):
"""Fetches pull request data and populates internal data structure."""
pull_request_number = options['github']
pull_request = url_to_json(pull_request_url(pull_request_number))
title = pull_request.get('title')
description = pull_request.get('body')
url = '{url}/{pr}'.format(url=GITHUB_URL, pr=pull_request_number)
author = get_author('{pr}.patch'.format(pr=pull_request_number))
message = '\n\n'.join([
title,
description,
'This closes #{pr}'.format(pr=pull_request_number)])
review_data = {
"summary": title,
"description": description,
"url": url,
"author": author,
"message": message
}
return review_data
def reviewboard_data(options):
"""Fetches review data and populates internal data structure."""
review_id = options['review_id']
# Populate review object.
review = url_to_json(review_api_url(review_id)).get('review_request')
url = review_url(review_id)
# Populate user object.
user = url_to_json(reviewboard_user_url(
review.get('links').get('submitter').get('title'))).get('user')
# Only include a description if it is not identical to the summary.
message_data = [review.get('summary')]
if review.get('description') != review.get('summary'):
message_data.append(review.get('description'))
message_data.append('Review: {review_url}'.format(review_url=url))
author = '{author} <{email}>'.format(
author=user.get('fullname'),
email=user.get('email'))
message = '\n\n'.join(message_data)
review_data = {
"summary": review.get('summary'),
"description": review.get('description'),
"url": url,
"author": author,
"message": message
}
return review_data
def parse_options():
"""Parses command line options and returns an option dictionary."""
options = {}
parser = argparse.ArgumentParser(
description='Recursively apply Review Board reviews'
' and GitHub pull requests.')
parser.add_argument('-d', '--dry-run',
action='store_true',
help='Perform a dry run.')
parser.add_argument('-k', '--keep-patches',
action='store_true',
help="Do not delete downloaded patch files.")
parser.add_argument('-n', '--no-amend',
action='store_true',
help='Do not amend commit message.')
parser.add_argument('-c', '--chain',
action='store_true',
help='Recursively apply parent review chain.')
parser.add_argument('-s', '--skip-hooks',
action='store_true',
help='Skip the commit hooks (e.g., Mesos style check).')
parser.add_argument('-3', '--3way',
dest='three_way',
action='store_true',
help='Use 3 way merge in git apply.')
# Add -g and -r and make them mutually exclusive.
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-g', '--github',
metavar='PULL_REQUEST',
help='Pull request number')
group.add_argument('-r', '--review-id',
metavar='REVIEW_ID',
help='Numeric review ID')
args = parser.parse_args()
options['review_id'] = args.review_id
options['dry_run'] = args.dry_run
options['keep_patches'] = args.keep_patches
options['no_amend'] = args.no_amend
options['github'] = args.github
options['chain'] = args.chain
options['skip_hooks'] = args.skip_hooks
options['3way'] = args.three_way
return options
def reviewboard(options):
"""Applies either a chain of reviewboard patches or a single patch."""
if options['chain']:
# Retrieve the list of reviews to apply.
applied = set()
for review_id, _ in review_chain(options['review_id']):
if review_id not in applied:
applied.add(review_id)
options['review_id'] = review_id
apply_review(options)
else:
apply_review(options)
def main():
"""
Main function to apply reviews.
"""
options = parse_options()
if options['review_id']:
reviewboard(options)
else:
apply_review(options)
if __name__ == "__main__":
main()