blob: 799f20d898f42262cd6eba8b603020b151fbe9e9 [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.
"""
Wrapper around the post-review/rbt tool provided by Review Board.
This script provides the ability to send one review for each
commit on the current branch, instead of squashing all changes
into a single large review. We encourage contributors to create
logical commits which can be reviewed independently.
Options to pass onto 'rbt' can be placed in a '.reviewboardrc'
file at the top of the Mesos source directory. A default
'.reviewboardrc' can be found at 'support/reviewboardrc'.
Running './bootstrap' will populate this file for you.
To use this script, first install 'RBTools' from Review Board:
http://www.reviewboard.org/downloads/rbtools/
$ cd /path/to/mesos
$ [ do some work on your branch off of master, make commit(s) ]
$ ./support/post-reviews.py
"""
# pylint: skip-file
import argparse
import atexit
import importlib.machinery
import importlib.util
import os
import platform
import re
import sys
import urllib.parse
from distutils.version import LooseVersion
from subprocess import check_output, Popen, PIPE, STDOUT
def execute(command, ignore_errors=False):
"""Execute a process and leave."""
process = None
try:
process = Popen(command,
stdin=PIPE,
stdout=PIPE,
stderr=STDOUT,
shell=False)
except Exception:
if not ignore_errors:
raise
return None
data, _ = process.communicate()
data = data.decode(sys.stdout.encoding)
status = process.wait()
if status != 0 and not ignore_errors:
cmdline = ' '.join(command) if isinstance(command, list) else command
need_login = 'Please log in to the Review Board' \
' server at reviews.apache.org.'
if need_login in data:
print(need_login, '\n')
print("You can either:")
print(" (1) Run 'rbt login', or")
print(" (2) Set the default USERNAME/PASSWORD in '.reviewboardrc'")
else:
print('Failed to execute: \'' + cmdline + '\':')
print(data)
sys.exit(1)
elif status != 0:
return None
return data
def main():
"""Main function, post commits added to this branch as review requests."""
# TODO(benh): Make sure this is a git repository, apologize if not.
# Choose 'rbt' if available, otherwise choose 'post-review'.
post_review = None
rbt_command = 'rbt'
# Windows command name must have `cmd` extension.
if platform.system() == 'Windows':
rbt_command = 'rbt.cmd'
rbt_version = execute([rbt_command, '--version'], ignore_errors=True)
if rbt_version:
rbt_version = LooseVersion(rbt_version)
post_review = [rbt_command, 'post']
elif execute(['post-review', '--version'], ignore_errors=True):
post_review = ['post-review']
else:
print('Please install RBTools before proceeding')
sys.exit(1)
# Warn if people have unstaged changes.
diff_stat = execute(['git', 'diff', '--shortstat']).strip()
if diff_stat:
print('WARNING: Worktree contains unstaged changes, continuing anyway.', file=sys.stderr)
# Warn if people have uncommitted changes.
diff_stat = execute(['git', 'diff', '--shortstat', '--staged']).strip()
if diff_stat:
print('WARNING: Worktree contains staged but uncommitted changes, ' \
'continuing anyway.', file=sys.stderr)
# Grab a reference to the repo's git directory. Usually this is simply
# .git in the repo's top level directory. However, when submodules are
# used, it may appear elsewhere. The most up-to-date way of finding this
# directory is to use `git rev-parse --git-common-dir`. This is necessary
# to support things like git worktree in addition to git submodules.
# However, as of January 2016, support for the '--git-common-dir' flag is
# fairly new, forcing us to fall back to the '--git-dir' flag if
# '--git-common-dir' is not supported. We do this by checking the output of
# git rev-parse --git-common-dir` and check if it gives a valid directory.
# If not, we set the git directory using the '--git-dir' flag instead.
git_dir = execute(['git', 'rev-parse', '--git-common-dir']).strip()
if not os.path.isdir(git_dir):
git_dir = execute(['git', 'rev-parse', '--git-dir']).strip()
# Grab a reference to the top level directory of this repo.
top_level_dir = execute(['git', 'rev-parse', '--show-toplevel']).strip()
# Use the tracking_branch specified by the user if exists.
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument(
'--server',
help='Specifies the Review Board server to use.')
parser.add_argument(
'--no-markdown',
action='store_true',
help='Specifies if the commit text should not be treated as Markdown.')
parser.add_argument(
'--bugs-closed',
help='The comma-separated list of bug IDs closed.')
parser.add_argument(
'--target-people',
help='The usernames of the people who should perform the review.')
parser.add_argument(
'--tracking-branch',
help='The remote tracking branch from which your local branch is derived.')
args, _ = parser.parse_known_args()
# Try to read the .reviewboardrc in the top-level directory.
reviewboardrc_filepath = os.path.join(top_level_dir, '.reviewboardrc')
if os.path.exists(reviewboardrc_filepath):
# Prevent generation of '.reviewboardrcc'.
sys.dont_write_bytecode = True
loader = importlib.machinery.SourceFileLoader(
'reviewboardrc', reviewboardrc_filepath)
spec = importlib.util.spec_from_loader(loader.name, loader)
reviewboardrc = importlib.util.module_from_spec(spec)
loader.exec_module(reviewboardrc)
if args.server:
reviewboard_url = args.server
elif 'REVIEWBOARD_URL' in dir(reviewboardrc):
reviewboard_url = reviewboardrc.REVIEWBOARD_URL
else:
reviewboard_url = 'https://reviews.apache.org'
if args.tracking_branch:
tracking_branch = args.tracking_branch
elif 'TRACKING_BRANCH' in dir(reviewboardrc):
tracking_branch = reviewboardrc.TRACKING_BRANCH
else:
tracking_branch = 'master'
branch_ref = execute(['git', 'symbolic-ref', 'HEAD']).strip()
branch = branch_ref.replace('refs/heads/', '', 1)
# Do not work on the tracking branch.
if branch == tracking_branch:
print("We're expecting you to be working on another branch" \
" from {}!".format(tracking_branch))
sys.exit(1)
temporary_branch = '_post-reviews_' + branch
# Always delete the temporary branch.
atexit.register(
lambda: execute(['git', 'branch', '-D', temporary_branch], True))
# Always put us back on the original branch.
atexit.register(lambda: execute(['git', 'checkout', branch]))
# Warn if the tracking branch is no direct ancestor of this review chain.
if execute([
'git', 'merge-base', '--is-ancestor', tracking_branch, branch_ref],
ignore_errors=True) is None:
print("WARNING: Tracking branch '%s' is no direct ancestor of HEAD." \
" Did you forget to rebase?" % tracking_branch, file=sys.stderr)
try:
input("Press enter to continue or 'Ctrl-C' to abort.\n")
except KeyboardInterrupt:
sys.exit(0)
merge_base = execute(
['git', 'merge-base', tracking_branch, branch_ref]).strip()
output = check_output([
'git',
'--no-pager',
'log',
'--pretty=format:%Cred%H%Creset -%C'
'(yellow)%d%Creset %s %Cgreen(%cr)%Creset',
merge_base + '..HEAD'])
print('Running \'%s\' across all of ...' % " ".join(post_review))
sys.stdout.buffer.write(output)
log = execute(['git',
'--no-pager',
'log',
'--no-color',
'--pretty=oneline',
'--reverse',
merge_base + '..HEAD']).strip()
if len(log) <= 0:
print("No new changes compared with master branch!")
sys.exit(1)
shas = []
for line in log.split('\n'):
sha = line.split()[0]
shas.append(sha)
previous = merge_base
parent_review_request_id = None
for i, sha in enumerate(shas):
execute(['git', 'branch', '-D', temporary_branch], True)
message = execute(['git',
'--no-pager',
'log',
'--pretty=format:%s%n%n%b',
previous + '..' + sha])
review_request_id = None
pos = message.find('Review:')
if pos != -1:
regex = 'Review: ({url})$'.format(
url=urllib.parse.urljoin(reviewboard_url, 'r/[0-9]+'))
pattern = re.compile(regex)
match = pattern.search(message[pos:].strip().strip('/'))
if match is None:
print("\nInvalid ReviewBoard URL: '{}'".format(message[pos:]))
sys.exit(1)
url = match.group(1)
review_request_id = url.split('/')[-1]
# Show the commit.
if review_request_id is None:
output = check_output([
'git',
'--no-pager',
'log',
'--pretty=format:%Cred%H%Creset -%C(yellow)%d%Creset %s',
previous + '..' + sha])
print('\nCreating diff of:')
sys.stdout.buffer.write(output)
else:
output = check_output([
'git',
'--no-pager',
'log',
'--pretty=format:%Cred%H%Creset -%C'
'(yellow)%d%Creset %s %Cgreen(%cr)%Creset',
previous + '..' + sha])
print('\nUpdating diff of:')
sys.stdout.buffer.write(output)
# Show the "parent" commit(s).
output = check_output([
'git',
'--no-pager',
'log',
'--pretty=format:%Cred%H%Creset -%C'
'(yellow)%d%Creset %s %Cgreen(%cr)%Creset',
tracking_branch + '..' + previous])
if output:
print('\n... with parent diff created from:')
sys.stdout.buffer.write(output)
try:
input('\nPress enter to continue or \'Ctrl-C\' to skip.\n')
except KeyboardInterrupt:
i = i + 1
previous = sha
parent_review_request_id = review_request_id
continue
# Strip the review url from the commit message, so that
# it is not included in the summary message when GUESS_FIELDS
# is set in .reviewboardc. Update the SHA appropriately.
if review_request_id:
stripped_message = message[:pos]
execute(['git', 'checkout', sha])
execute(['git', 'commit', '--amend', '-m', stripped_message])
sha = execute(['git', 'rev-parse', 'HEAD']).strip()
execute(['git', 'checkout', branch])
revision_range = previous + ':' + sha
# Build the post-review/rbt command up
# to the point where they are common.
command = post_review
if not args.no_markdown:
command = command + ['--markdown']
if args.bugs_closed:
command = command + ['--bugs-closed=' + args.bugs_closed]
if args.target_people:
command = command + ['--target-people=' + args.target_people]
if args.tracking_branch is None:
command = command + ['--tracking-branch=' + tracking_branch]
if review_request_id:
command = command + ['--review-request-id=' + review_request_id]
# Determine how to specify the revision range.
if rbt_command in post_review and \
rbt_version >= LooseVersion('RBTools 0.6'):
# rbt >= 0.6.1 supports '--depends-on' argument.
# Only set the "depends on" if this
# is not the first review in the chain.
if rbt_version >= LooseVersion('RBTools 0.6.1') and \
parent_review_request_id:
command = command + ['--depends-on=' + parent_review_request_id]
# rbt >= 0.6 revisions are passed in as args.
command = command + sys.argv[1:] + [previous, sha]
else:
# post-review and rbt < 0.6 revisions are
# passed in using the revision range option.
command = command + \
['--revision-range=' + revision_range] + \
sys.argv[1:]
output = execute(command)
# Output is a string, we convert it to a byte string before writing it.
sys.stdout.buffer.write(output.encode())
# If we already have a request_id, continue on to the next commit in the
# chain. We update 'previous' from the shas[] array because we have
# overwritten the temporary sha variable above.
if review_request_id is not None:
previous = shas[i]
parent_review_request_id = review_request_id
i = i + 1
continue
# Otherwise, get the request_id from the output of post-review, append
# it to the commit message and rebase all other commits on top of it.
lines = output.split('\n')
# The last line of output in post-review is the review url.
# The second to the last line of output in rbt is the review url.
url = lines[len(lines) - 2] if rbt_command in post_review \
else lines[len(lines) - 1]
# Using rbt >= 0.6.3 on Linux prints out two URLs where the second
# one has /diff/ at the end. We want to remove this so that a
# subsequent call to post-reviews does not fail when looking up
# the reviewboard entry to edit.
url = url.replace('diff/', '')
url = url.strip('/')
review_request_id = os.path.basename(url)
# Construct new commit message.
message = message + '\n' + 'Review: ' + url + '\n'
execute(['git', 'checkout', '-b', temporary_branch])
execute(['git', 'reset', '--hard', sha])
execute(['git', 'commit', '--amend', '-m', message])
# Now rebase all remaining shas on top of this amended commit.
j = i + 1
old_sha = execute(
['git', 'rev-parse', '--verify', temporary_branch]).strip()
previous = old_sha
while j < len(shas):
execute(['git', 'checkout', shas[j]])
execute(['git', 'rebase', temporary_branch])
# Get the sha for our detached HEAD.
new_sha = execute([
'git',
'--no-pager',
'log',
'--pretty=format:%H', '-n', '1', 'HEAD']).strip()
execute(['git',
'update-ref',
'refs/heads/' + temporary_branch,
new_sha,
old_sha])
old_sha = new_sha
shas[j] = new_sha
j = j + 1
# Okay, now update the actual branch to our temporary branch.
new_sha = old_sha
old_sha = execute(['git', 'rev-parse', '--verify', branch]).strip()
execute(['git', 'update-ref', 'refs/heads/' + branch, new_sha, old_sha])
i = i + 1
parent_review_request_id = review_request_id
if __name__ == '__main__':
main()