#!/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 packaging.version import Version 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 = Version(rbt_version.split(' ')[1]) 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 >= Version('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 >= Version('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()