From a2b0de65dd3f9936df8df12901c55f6c90abb8c8 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 22 Nov 2012 10:31:13 -0800 Subject: [PATCH] Added trivial_rebase script. --- gerritx/cmd/trivial_rebase.py | 262 ++++++++++++++++++++++++++++++++++ setup.py | 1 + 2 files changed, 263 insertions(+) create mode 100644 gerritx/cmd/trivial_rebase.py diff --git a/gerritx/cmd/trivial_rebase.py b/gerritx/cmd/trivial_rebase.py new file mode 100644 index 0000000..19006f9 --- /dev/null +++ b/gerritx/cmd/trivial_rebase.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python2.6 + +# Copyright (c) 2010, Code Aurora Forum. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# # Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# # Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# # Neither the name of Code Aurora Forum, Inc. nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED +# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS +# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +# IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# This script is designed to detect when a patchset uploaded to Gerrit is +# 'identical' (determined via git-patch-id) and reapply reviews onto the new +# patchset from the previous patchset. + +# Get usage and help info by running: ./trivial_rebase.py --help +# Documentation is available here: https://www.codeaurora.org/xwiki/bin/QAEP/Gerrit + +import json +import subprocess +from sys import exit + +from optparse import OptionParser as _realOptionParser, AmbiguousOptionError, \ + BadOptionError +class OptionParser(_realOptionParser): + """Make OptionParser silently swallow unrecognized options.""" + def _process_args(self, largs, rargs, values): + while rargs: + try: + _realOptionParser._process_args(self, largs, rargs, values) + except (AmbiguousOptionError, BadOptionError), e: + largs.append(e.opt_str) + +class CheckCallError(OSError): + """CheckCall() returned non-0.""" + def __init__(self, command, cwd, retcode, stdout, stderr=None): + OSError.__init__(self, command, cwd, retcode, stdout, stderr) + self.command = command + self.cwd = cwd + self.retcode = retcode + self.stdout = stdout + self.stderr = stderr + +def CheckCall(command, cwd=None): + """Like subprocess.check_call() but returns stdout. + + Works on python 2.4 + """ + try: + process = subprocess.Popen(command, cwd=cwd, stdout=subprocess.PIPE) + std_out, std_err = process.communicate() + except OSError, e: + raise CheckCallError(command, cwd, e.errno, None) + if process.returncode: + raise CheckCallError(command, cwd, process.returncode, std_out, std_err) + return std_out, std_err + +def Gssh(options, api_command): + """Makes a Gerrit API call via SSH and returns the stdout results.""" + ssh_cmd = ['ssh', + '-l', 'Gerrit Code Review', + '-p', options.port, + '-i', options.private_key_path, + options.server, + api_command] + try: + return CheckCall(ssh_cmd)[0] + except CheckCallError, e: + import sys + err_template = "call: %s\nreturn code: %s\nstdout: %s\nstderr: %s\n" + sys.stderr.write(err_template%(ssh_cmd, e.retcode, e.stdout, e.stderr)) + raise + +def GsqlQuery(sql_query, options): + """Runs a gerrit gsql query and returns the result""" + gsql_cmd = "gerrit gsql --format JSON -c %s"%sql_query + gsql_out = Gssh(options, gsql_cmd) + new_out = gsql_out.replace('}}\n', '}}\nsplit here\n') + return new_out.split('split here\n') + +def FindPrevRev(options): + """Finds the revision of the previous patch set on the change""" + sql_query = ("\"SELECT revision FROM patch_sets,changes WHERE " + "patch_sets.change_id = changes.change_id AND " + "patch_sets.patch_set_id = %s AND " + "changes.change_key = \'%s\'\"" % ((options.patchset - 1), + options.changeId)) + revisions = GsqlQuery(sql_query, options) + + json_dict = json.loads(revisions[0], strict=False) + return json_dict["columns"]["revision"] + +def GetApprovals(options): + """Get all the approvals on a specific patch set + + Returns a list of approval dicts""" + sql_query = ("\"SELECT value,account_id,category_id FROM patch_set_approvals " + "WHERE patch_set_id = %s AND change_id = (SELECT change_id FROM " + "changes WHERE change_key = \'%s\') AND value <> 0\"" + % ((options.patchset - 1), options.changeId)) + gsql_out = GsqlQuery(sql_query, options) + approvals = [] + for json_str in gsql_out: + dict = json.loads(json_str, strict=False) + if dict["type"] == "row": + approvals.append(dict["columns"]) + return approvals + +def GetPatchId(revision, consider_whitespace=False): + git_show_cmd = ['git', 'show', revision] + patch_id_cmd = ['git', 'patch-id'] + patch_id_process = subprocess.Popen(patch_id_cmd, stdout=subprocess.PIPE, + stdin=subprocess.PIPE) + git_show_process = subprocess.Popen(git_show_cmd, stdout=subprocess.PIPE) + if consider_whitespace: + # This matches on change lines in the patch (those starting with "+" + # or "-" but not followed by another of the same), then replaces any + # space or tab characters with "%" before calculating a patch-id. + replace_ws_cmd = ['sed', r'/^\(+[^+]\|-[^-]\)/y/ \t/%%/'] + replace_ws_process = subprocess.Popen(replace_ws_cmd, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE) + return patch_id_process.communicate( + replace_ws_process.communicate(git_show_process.communicate()[0])[0] + )[0] + else: + return patch_id_process.communicate(git_show_process.communicate()[0])[0] + +def SuExec(options, as_user, cmd): + suexec_cmd = "suexec --as %s -- %s"%(as_user, cmd) + Gssh(options, suexec_cmd) + +def DiffCommitMessages(commit1, commit2): + log_cmd1 = ['git', 'log', '--pretty=format:"%an %ae%n%s%n%b"', + commit1 + '^!'] + commit1_log = CheckCall(log_cmd1) + log_cmd2 = ['git', 'log', '--pretty=format:"%an %ae%n%s%n%b"', + commit2 + '^!'] + commit2_log = CheckCall(log_cmd2) + if commit1_log != commit2_log: + return True + return False + +def main(): + usage = "usage: %prog [optional options]" + parser = OptionParser(usage=usage) + parser.add_option("--change", dest="changeId", help="Change identifier") + parser.add_option("--project", help="Project path in Gerrit") + parser.add_option("--commit", help="Git commit-ish for this patchset") + parser.add_option("--patchset", type="int", help="The patchset number") + parser.add_option("--role-user", dest="role_user", + help="E-mail/ID of user commenting on commit messages") + parser.add_option("--private-key-path", dest="private_key_path", + help="Full path to Gerrit SSH daemon's private host key") + parser.add_option("--server-port", dest="port", default='29418', + help="Port to connect to Gerrit's SSH daemon " + "[default: %default]") + parser.add_option("--server", dest="server", default="localhost", + help="Server name/address for Gerrit's SSH daemon " + "[default: %default]") + parser.add_option("--whitespace", action="store_true", + help="Treat whitespace as significant") + + (options, args) = parser.parse_args() + + if not options.changeId: + parser.print_help() + exit(0) + + if options.patchset == 1: + # Nothing to detect on first patchset + exit(0) + prev_revision = None + prev_revision = FindPrevRev(options) + if not prev_revision: + # Couldn't find a previous revision + exit(0) + prev_patch_id = GetPatchId(prev_revision) + cur_patch_id = GetPatchId(options.commit) + if cur_patch_id.split()[0] != prev_patch_id.split()[0]: + # patch-ids don't match + exit(0) + # Patch ids match. This is a trivial rebase. + # In addition to patch-id we should check if whitespace content changed. Some + # languages are more sensitive to whitespace than others, and some changes + # may either introduce or be intended to fix style problems specifically + # involving whitespace as well. + if options.whitespace: + prev_patch_ws = GetPatchId(prev_revision, consider_whitespace=True) + cur_patch_ws = GetPatchId(options.commit, consider_whitespace=True) + if cur_patch_ws.split()[0] != prev_patch_ws.split()[0]: + # Insert a comment into the change letting the approvers know only the + # whitespace changed + comment_msg = "\"New patchset patch-id matches previous patchset, " \ + "but whitespace content has changed.\"" + comment_cmd = ['gerrit', 'approve', '--project', options.project, + '--message', comment_msg, options.commit] + SuExec(options, options.role_user, ' '.join(comment_cmd)) + exit(0) + + # We should also check if the commit message changed. Most approvers would + # want to re-review changes when the commit message changes. + changed = DiffCommitMessages(prev_revision, options.commit) + if changed: + # Insert a comment into the change letting the approvers know only the + # commit message changed + comment_msg = "\"New patchset patch-id matches previous patchset, " \ + "but commit message has changed.\"" + comment_cmd = ['gerrit', 'approve', '--project', options.project, + '--message', comment_msg, options.commit] + SuExec(options, options.role_user, ' '.join(comment_cmd)) + exit(0) + + # Need to get all approvals on prior patch set, then suexec them onto + # this patchset. + approvals = GetApprovals(options) + gerrit_approve_msg = ("\'Automatically re-added by Gerrit trivial rebase " + "detection script.\'") + for approval in approvals: + # Note: Sites with different 'copy_min_score' values in the + # approval_categories DB table might want different behavior here. + # Additional categories should also be added if desired. + if approval["category_id"] == "CRVW": + approve_category = '--code-review' + elif approval["category_id"] == "VRIF": + # Don't re-add verifies + #approve_category = '--verified' + continue + elif approval["category_id"] == "SUBM": + # We don't care about previous submit attempts + continue + elif approval["category_id"] == "APRV": + # Similarly squash old approvals + continue + else: + print "Unsupported category: %s" % approval + exit(0) + + score = approval["value"] + gerrit_approve_cmd = ['gerrit', 'approve', '--project', options.project, + '--message', gerrit_approve_msg, approve_category, + score, options.commit] + SuExec(options, approval["account_id"], ' '.join(gerrit_approve_cmd)) + exit(0) diff --git a/setup.py b/setup.py index 2a87690..f5f709b 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ setuptools.setup( 'notify-impact=gerritx.cmd.notify_impact:main', 'process-cache=gerritx.cmd.process_cache:main', 'run-mirror=gerritx.cmd.run_mirror:main', + 'trivial-rebase=gerritx.cmd.trivial_rebase:main', 'update-blueprint=gerritx.cmd.update_blueprint:main', 'update-bug=gerritx.cmd.update_bug:main', ],