blob: e4620e893a77dc2442b63b6d9c49626d82860d8d [file] [log] [blame]
#!/usr/bin/env python
# This is a wrapper around the 'post-review'/'rbt' tool provided by
# Review Board. This is currently used by Apache Mesos development.
#
# What does this do?
# It provides the ability to send a review for each commit on the
# current branch.
#
# Why is that useful?
# No one likes a 5000 line review request. Using this tool forces one
# to create logical commits which can be reviewed independently.
#
# How do I use it?
# 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 --server=https://reviews.apache.org \
# --tracking-branch=origin/master --target-groups=mesos --open
#
# NOTE: post-reviews is currently specific to Mesos development,
# but can easily be adapted for other projects.
import argparse
import atexit
import imp
import os
import re
import sys
from distutils.version import LooseVersion
from subprocess import check_output, Popen, PIPE, STDOUT
def execute(command, ignore_errors=False):
process = None
try:
process = Popen(command,
stdin=PIPE,
stdout=PIPE,
stderr=STDOUT,
shell=False)
except:
if not ignore_errors:
raise
return None
data, error = process.communicate()
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
# TODO(benh): Make sure this is a git repository, apologize if not.
# Choose 'rbt' if available, otherwise choose 'post-review'.
post_review = None
rbt_version = execute(['rbt', '--version'], ignore_errors=True)
if rbt_version:
rbt_version = LooseVersion(rbt_version)
post_review = ['rbt', 'post']
elif execute(['post-review', '--version'], ignore_errors=True):
post_review = ['post-review']
else:
print 'Please install RBTools before proceeding'
sys.exit(1)
# Don't do anything if people have unstaged changes.
diff_stat = execute(['git', 'diff', '--shortstat']).strip()
if diff_stat:
print 'Please commit or stash any changes before using post-reviews!'
sys.exit(1)
# Don't do anything if people have uncommitted changes.
diff_stat = execute(['git', 'diff', '--shortstat', '--staged']).strip()
if diff_stat:
print 'Please commit staged changes before using post-reviews!'
sys.exit(1)
# 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 older '--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 seeing
# if it gives us a valid directory back. 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=False)
parser.add_argument('--server')
parser.add_argument('--tracking-branch')
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):
sys.dont_write_bytecode = True # Prevent generation of '.reviewboardrcc'.
reviewboardrc = imp.load_source('reviewboardrc', reviewboardrc_filepath)
reviewboard_url = (
args.server if args.server else
reviewboardrc.REVIEWBOARD_URL if 'REVIEWBOARD_URL' in dir(reviewboardrc) else
'https://reviews.apache.org')
tracking_branch = (
args.tracking_branch if args.tracking_branch else
reviewboardrc.TRACKING_BRANCH if 'TRACKING_BRANCH' in dir(reviewboardrc) else
'master')
branch_ref = execute(['git', 'symbolic-ref', 'HEAD']).strip()
branch = branch_ref.replace('refs/heads/', '', 1)
# do not work on master branch
if branch == "master":
print "We're expecting you to be working on another branch from master!"
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]))
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)
print 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 = tracking_branch
parent_review_request_id = None
for i in range(len(shas)):
sha = shas[i]
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:
pattern = re.compile('Review: ({url})$'.format(
url=os.path.join(reviewboard_url, 'r', '[0-9]+')))
match = pattern.search(message.strip().strip('/'))
if match is None:
print "\nInvalid ReviewBoard URL: '{}'".format(message[pos:])
sys.exit(1)
url = match.group(1)
review_request_id = os.path.basename(url)
# 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:'
print 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:'
print 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:'
print output
try:
raw_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 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' 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).strip()
print output
# 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' 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(['cat', os.path.join(git_dir, 'refs/heads', 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(['cat', os.path.join(git_dir, 'refs/heads', branch)]).strip()
execute(['git', 'update-ref', 'refs/heads/' + branch, new_sha, old_sha])
i = i + 1
parent_review_request_id = review_request_id