|  | #!/usr/bin/env python | 
|  |  | 
|  | # 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 | 
|  |  | 
|  | """ | 
|  |  | 
|  | from __future__ import print_function | 
|  |  | 
|  | import argparse | 
|  | import json | 
|  | import re | 
|  | import subprocess | 
|  | import sys | 
|  |  | 
|  | class TrivialRebase: | 
|  | def __init__(self): | 
|  | usage = "%(prog)s <required options> [--server-port=PORT]" | 
|  | parser = argparse.ArgumentParser(usage=usage) | 
|  | parser.add_argument("--change-url", dest="changeUrl", help="Change URL") | 
|  | parser.add_argument("--project", help="Project path in Gerrit") | 
|  | parser.add_argument("--commit", help="Git commit-ish for this patchset") | 
|  | parser.add_argument("--patchset", type=int, help="The patchset number") | 
|  | parser.add_argument("--private-key-path", dest="private_key_path", | 
|  | help="Full path to Gerrit SSH daemon's private host key") | 
|  | parser.add_argument("--server", default='localhost', | 
|  | help="Gerrit SSH server [default: %(default)s]") | 
|  | parser.add_argument("--server-port", dest="port", default='29418', | 
|  | help="Port to connect to Gerrit's SSH daemon " | 
|  | "[default: %(default)s]") | 
|  | parser.add_argument("--ssh", default="ssh", help="SSH executable") | 
|  | parser.add_argument("--ssh-port-flag", dest="ssh_port_flag", default="-p", help="SSH port flag") | 
|  |  | 
|  | args = parser.parse_known_args()[0] | 
|  | if None in [args.changeUrl, args.project, args.commit, args.patchset]: | 
|  | parser.error("Incomplete arguments") | 
|  | try: | 
|  | self.changeId = re.search(r'\d+$', args.changeUrl).group() | 
|  | except AttributeError: | 
|  | parser.error("Invalid changeId") | 
|  | self.project = args.project | 
|  | self.commit = args.commit | 
|  | self.patchset = args.patchset | 
|  | self.private_key_path = args.private_key_path | 
|  | self.server = args.server | 
|  | self.port = args.port | 
|  | self.ssh = args.ssh | 
|  | self.ssh_port_flag = args.ssh_port_flag | 
|  |  | 
|  | 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(self, 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, stderr=subprocess.PIPE) | 
|  | std_out, std_err = process.communicate() | 
|  | except OSError as e: | 
|  | raise self.CheckCallError(command, cwd, e.errno, None) | 
|  | if process.returncode: | 
|  | raise self.CheckCallError(command, cwd, process.returncode, std_out, std_err) | 
|  | return std_out, std_err | 
|  |  | 
|  | def GsqlQuery(self, sql_query): | 
|  | """Run a gerrit gsql query and return the result.""" | 
|  | gsql_cmd = [self.ssh, self.ssh_port_flag, self.port, self.server, 'gerrit', 'gsql', | 
|  | '--format', 'JSON', '-c', sql_query] | 
|  | try: | 
|  | (gsql_out, _gsql_stderr) = self.CheckCall(gsql_cmd) | 
|  | except self.CheckCallError as e: | 
|  | print("return code is %s" % e.retcode) | 
|  | print("stdout and stderr is\n%s%s" % (e.stdout, e.stderr)) | 
|  | raise | 
|  |  | 
|  | new_out = gsql_out.replace('}}\n', '}}\nsplit here\n') | 
|  | return new_out.split('split here\n') | 
|  |  | 
|  | def FindPrevRev(self): | 
|  | """Find the revision of the previous patch set on the change.""" | 
|  | sql_query = ("\"SELECT revision FROM patch_sets WHERE " | 
|  | "change_id = %s AND patch_set_id = %s\"" % | 
|  | (self.changeId, (self.patchset - 1))) | 
|  | revisions = self.GsqlQuery(sql_query) | 
|  |  | 
|  | json_dict = json.loads(revisions[0], strict=False) | 
|  | return json_dict["columns"]["revision"] | 
|  |  | 
|  | def GetApprovals(self): | 
|  | """Get all the approvals on a specific patch set. | 
|  |  | 
|  | Returns a list of approval dicts. | 
|  |  | 
|  | """ | 
|  | sql_query = ("\"SELECT value,account_id,category_id AS label FROM patch_set_approvals " | 
|  | "WHERE change_id = %s AND patch_set_id = %s AND value != 0\"" | 
|  | % (self.changeId, (self.patchset - 1))) | 
|  | gsql_out = self.GsqlQuery(sql_query) | 
|  | approvals = [] | 
|  | for json_str in gsql_out: | 
|  | data = json.loads(json_str, strict=False) | 
|  | if data["type"] == "row": | 
|  | approvals.append(data["columns"]) | 
|  | return approvals | 
|  |  | 
|  | def AppendAcctApproval(self, account_id, value): | 
|  | try: | 
|  | newval = self.acct_approvals[account_id] + ' ' + value | 
|  | except KeyError: | 
|  | newval = value | 
|  | self.acct_approvals[account_id] = newval | 
|  |  | 
|  | def GetEmailFromAcctId(self, account_id): | 
|  | """Return the preferred email address associated with the account_id.""" | 
|  | sql_query = ("\"SELECT preferred_email FROM accounts WHERE account_id = %s\"" | 
|  | % account_id) | 
|  | email_addr = self.GsqlQuery(sql_query) | 
|  |  | 
|  | json_dict = json.loads(email_addr[0], strict=False) | 
|  | return json_dict["columns"]["preferred_email"] | 
|  |  | 
|  | def GetPatchId(self, revision): | 
|  | git_show_cmd = ['git', 'show', revision] | 
|  | patch_id_cmd = ['git', 'patch-id'] | 
|  | git_show_process = subprocess.Popen(git_show_cmd, stdout=subprocess.PIPE) | 
|  | patch_id_process = subprocess.Popen(patch_id_cmd, stdout=subprocess.PIPE, | 
|  | stdin=git_show_process.stdout) | 
|  | res = patch_id_process.communicate()[0] or '0' | 
|  | return res.split()[0] | 
|  |  | 
|  | def SuExec(self, as_user, cmd): | 
|  | suexec_cmd = [self.ssh, '-l', "Gerrit Code Review", self.ssh_port_flag, self.port, self.server] | 
|  | if self.private_key_path: | 
|  | suexec_cmd += ['-i', self.private_key_path] | 
|  | suexec_cmd += ['suexec', '--as', as_user, '--', cmd] | 
|  | self.CheckCall(suexec_cmd) | 
|  |  | 
|  | def DiffCommitMessages(self, prev_commit): | 
|  | log_cmd1 = ['git', 'log', '--pretty=format:"%an %ae%n%s%n%b"', | 
|  | prev_commit + '^!'] | 
|  | commit1_log = self.CheckCall(log_cmd1) | 
|  | log_cmd2 = ['git', 'log', '--pretty=format:"%an %ae%n%s%n%b"', | 
|  | self.commit + '^!'] | 
|  | commit2_log = self.CheckCall(log_cmd2) | 
|  | if commit1_log != commit2_log: | 
|  | return True | 
|  | return False | 
|  |  | 
|  | def Run(self): | 
|  | if self.patchset == 1: | 
|  | # Nothing to detect on first patchset | 
|  | return | 
|  | prev_revision = self.FindPrevRev() | 
|  | assert prev_revision, "Previous revision not found" | 
|  | prev_patch_id = self.GetPatchId(prev_revision) | 
|  | cur_patch_id = self.GetPatchId(self.commit) | 
|  | if prev_patch_id == '0' and cur_patch_id == '0': | 
|  | print("commits %s and %s are both empty or merge commits" % (prev_revision, self.commit)) | 
|  | return | 
|  | if cur_patch_id != prev_patch_id: | 
|  | # patch-ids don't match | 
|  | return | 
|  | # Patch ids match. This is a trivial rebase. | 
|  | # In addition to patch-id we should check if the commit message changed. Most | 
|  | # approvers would want to re-review changes when the commit message changes. | 
|  | changed = self.DiffCommitMessages(prev_revision) | 
|  | if changed: | 
|  | # Insert a comment into the change letting the approvers know only the | 
|  | # commit message changed | 
|  | comment_msg = ("\'--message=New patchset patch-id matches previous patchset" | 
|  | ", but commit message has changed.'") | 
|  | comment_cmd = [self.ssh, self.ssh_port_flag, self.port, self.server, 'gerrit', | 
|  | 'review', '--project', self.project, comment_msg, self.commit] | 
|  | self.CheckCall(comment_cmd) | 
|  | return | 
|  |  | 
|  | # Need to get all approvals on prior patch set, then suexec them onto | 
|  | # this patchset. | 
|  | approvals = self.GetApprovals() | 
|  | self.acct_approvals = dict() | 
|  | 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["label"] == "Code-Review": | 
|  | if approval['value'] != '-2': | 
|  | self.AppendAcctApproval(approval['account_id'], | 
|  | '--label Code-Review=%s' % approval['value']) | 
|  | elif approval["label"] == "Verified": | 
|  | # Don't re-add verifies | 
|  | # self.AppendAcctApproval(approval['account_id'], '--label Verified=%s' % approval['value']) | 
|  | continue | 
|  | elif approval["label"] == "SUBM": | 
|  | # We don't care about previous submit attempts | 
|  | continue | 
|  | else: | 
|  | self.AppendAcctApproval(approval['account_id'], '--label %s=%s' % | 
|  | (approval['label'], approval['value'])) | 
|  |  | 
|  | gerrit_review_msg = ("\'Automatically re-added by Gerrit trivial rebase " | 
|  | "detection script.\'") | 
|  | for acct, flags in list(self.acct_approvals.items()): | 
|  | gerrit_review_cmd = ['gerrit', 'review', '--project', self.project, | 
|  | '--message', gerrit_review_msg, flags, self.commit] | 
|  | email_addr = self.GetEmailFromAcctId(acct) | 
|  | self.SuExec(email_addr, ' '.join(gerrit_review_cmd)) | 
|  |  | 
|  | if __name__ == "__main__": | 
|  | try: | 
|  | TrivialRebase().Run() | 
|  | except AssertionError as e: | 
|  | print(e, file=sys.stderr) |