#!/usr/bin/env python
"""
This dispatcher handles the hook execution
Allowed tags in gerrit commit message:
======================================
Ignore-Hooks
--------------
Do not run the given hooks::
Ignore-Hooks: hook1, hook2, ...
Run-Only-Hooks
---------------
Run only the given hooks if they exist::
Run-Only-Hooks: hook1, hook2 ...
Allowed tags in gerrit comment:
================================
Rerun-Hooks
------------
Run again the given hooks (patchset-created event hooks only)::
Rerun-Hooks: hook1, hook2 ...
Rerun-Hooks: all
API
=====
"""
import os
import sys
import re
import subprocess
import argparse
import logging
from dulwich.repo import Repo
# Load also the hook libs
os.environ["PATH"] = os.environ["PATH"] + ':' \
+ os.path.dirname(os.path.realpath(__file__)) + '/lib'
os.environ["PYTHONPATH"] = os.environ.get("PYTHONPATH", "") \
+ ':' + os.path.dirname(os.path.realpath(__file__)) + '/lib'
from lib import (
gerrit,
config,
)
[docs]class LoggerWriter:
"""Redirect writes to the given logger."""
def __init__(self, logger, level):
self.logger = logger
self.level = level
[docs] def write(self, message):
for line in message.rstrip().splitlines():
self.logger.log(self.level, line.rstrip())
[docs]def csv_to_set(csv_string):
return set([elem.strip() for elem in csv_string.split(',')])
[docs]def ignore(ignored_hooks, current_hooks):
"""
Return the hooks list without the given hooks
:param ignored_hooks; csv lis tof the hooks to ignore
:param current_hooks: array with the currently available hooks
"""
ignored_hooks = csv_to_set(ignored_hooks)
logging.info(" Ignoring the hooks %s", ignored_hooks)
for hook in ignored_hooks:
match = filter(lambda h: re.match(hook, h), current_hooks)
if match:
for mhook in match:
current_hooks.pop(current_hooks.index(mhook))
return current_hooks
[docs]def run_only(to_run_hooks, current_hooks):
"""
Handles the Run-Only tag, removes all the hooks not matching the given
hooks
:param to_run_hooks: array with the csv string passed to the tag
:param current_hooks: array with the current available hooks
"""
to_run_hooks = csv_to_set(to_run_hooks)
logging.info(" Running only the hooks %s", to_run_hooks)
to_run = []
for hook in to_run_hooks:
if hook in current_hooks:
to_run.append(hook)
return to_run
[docs]def rerun(tag_val, to_rerun_hooks, args):
"""
Handles the Rerun-Hooks tag, reruns the given hooks,
if all is given it will rerun all the hooks
:param tag_val: string passed to "Rerun-Hooks:" tag in comment
:param to_rerun_hooks: array with the csv string passed to the tag
:param args: object with all the passed arguments
:return list of hooks that will be rerun
"""
# When rerunning use only the patchset-created hooks
current_hooks = get_hooks(os.path.join(os.environ['GIT_DIR'], 'hooks'),
'patchset-created')
to_rerun_hooks = csv_to_set(tag_val)
logging.info(" Rerunning the hooks %s", to_rerun_hooks)
to_rerun = []
args.rerun = True
for hook in to_rerun_hooks:
if not hook.startswith('patchset-created') and hook != 'all':
hook = 'patchset-created.' + hook
if hook in current_hooks:
to_rerun.append(hook)
elif hook == 'all':
return current_hooks
return to_rerun
# Gerrit comment tags
# Tags to look for should be specified like this:
#
# {
# tag_name: (
# Regexp_to_match,
# function_to_run
# )
# }
COMMENT_TAGS = {
'rerun': (
'Rerun-Hooks: ',
rerun
),
}
# Commit message tags
COMMIT_TAGS = {
'ignore': (
'Ignore-Hooks: ',
ignore
),
'run_only': (
'Run-Only-Hooks: ',
run_only
),
}
[docs]def get_parser():
"""
Build the parser for any hook type
"""
parser = argparse.ArgumentParser(
description=('This dispatcher handles the'
' special tags and hook'
' execution'),
)
for arg in ('change', 'project', 'branch'):
parser.add_argument('--' + arg,
action='store',
required=True)
for arg in ('commit', 'patchset', 'author', 'comment', 'submitter',
'abandoner', 'reason', 'rerun', 'kind'):
parser.add_argument('--' + arg,
action='store',
default=False,
required=False)
return parser
[docs]def flatten(array, elems):
"""
Function that appends to the array the options given by elems, to build a
new command line array.
:param array: Command line array as ['ls', '-l', '/tmp']
:param elems: Command line options as parsed by argparse, like
[('author', 'dcaro'), ('email', 'dcaroest@redhat.com')]
"""
option, value = elems
if value:
array.extend(('--' + option, value))
return array
[docs]def get_hooks(path, hook_type):
"""
Get all the hooks that match the given type in the given path
:param path: Path to look the hooks in
:param hook_type: Hook type to look for
"""
logging.info("==> HOOKS PATH::{0}".format(path))
hooks = []
for hook in os.listdir(path):
if os.access(os.path.join(path, hook), os.X_OK) \
and hook.startswith(hook_type):
hooks.append(hook)
return hooks
[docs]def get_chains(hooks):
chains = {}
hooks.sort()
for hook in hooks:
chain = hook if '.' not in hook else hook.split('.')[0]
if chain in chains:
chains[chain].append(hook)
continue
chains[chain] = [hook]
return chains
[docs]def parse_stdout(stdout):
"""
The expected output format is:
code_review_value
verified_value
multi_line_msg
Where code_review_value and verified_value can be an empty line if it's
an abstention (no modifying the value any of the previous or next hooks
in the chain set)
If any of the lines is missing then it will assumed an abstention.
* 2 lines -> No message
* 1 line -> No message and no verified value
* 0 lines -> Not changing anything
And if any of the two first lines fails when converting to integer, it
will assume that's the start of the message and count the non-specified
votes as abstentions
Examples:
Skipping hooks comment - return codes:
If rc == 3 the execution of the hook will not be added to the comments, on
any other case, it will add a comment with the message provided
message starts here
-1
message continues
^^^ this will be parsed as only message and two abstentions
-1
message starts here
0
message continues here
^^^ this will count as CR:-1, and the rest message
0
message
^^^ This will count as CR:0 (will level any positive review) and the rest
message
"""
result = {
'code_review': None,
'verified': None,
'msg': None,
}
if not stdout:
return result
elem = ''
for elem_name in ('code_review', 'verified'):
try:
elem = stdout.pop(0)
# So we don't have a number, the message should start here
except ValueError:
# put the line back
stdout.insert(0, elem)
break
# non-voting
if not elem:
elem = None
continue
# voting
elem = int(elem)
result[elem_name] = elem
result['msg'] = '\n'.join(stdout) or None
return result
[docs]def run_hooks(path, hooks):
"""
Run the given hooks form the given path
If any of the hooks returns with 1 or 2, the execution is stopped, if
returns with 3, only a warning message is logged, and the execution will
continue.
:param path: Path where the hooks are located
:param hooks: hook names to run
:returns str: Log string with the combined result of all the executed hooks
"""
hooks.sort()
params = sys.argv[1:]
global_result = None
for hook in hooks:
cmd = [os.path.join(path, hook)]
cmd.extend(params)
logging.info("==> RUNNING HOOK::{0}".format(' '.join(cmd)))
pipe = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = pipe.communicate()
error and logging.info(hook + '\n' + error)
if output:
logging.debug("==> OUTPUT::\n" + "#" * 80 +
"\n{0}".format(output) + "#" * 80 + "\n")
output = output.splitlines()
if pipe.returncode == 3:
continue
result = parse_stdout(output)
if global_result is None:
global_result = result
else:
for elem in ('code_review', 'verified'):
if result[elem] is not None \
and result[elem] < global_result[elem]:
global_result[elem] = result[elem]
if result['msg']:
global_result['msg'] = \
str(global_result['msg']) + '\n' + result['msg']
if pipe.returncode != 0:
err_msg = "BROKEN WITH RETURN CODE::" + str(pipe.returncode)
logging.error('==> RESULT::{0}'.format(err_msg))
logging.info('-' * 80)
logging.error(str(result))
return global_result
logging.info('==> RESULT::{0}'.format(str(result)))
logging.info('-' * 80)
return global_result
[docs]def run_chains(path, hooks):
"""
Builds the chains from the given hooks and runs them.
If a hook on a chain returns with 1 or 2, it will break that chain and no
futher hooks for that chain will be executed, continuing with the next
chain
:param path: Path where the hooks are located
:param hooks: hook names to run
"""
# add the hooks lib dir to the path
results = {}
chains = get_chains(hooks)
for c_name, c_hooks in chains.iteritems():
results[c_name] = run_hooks(path, c_hooks)
if results[c_name] is None:
results.pop(c_name)
continue
if results[c_name]['msg'] is None:
results[c_name]['msg'] = "* {0}: OK".format(c_name)
logging.info("==> TOTAL RESULTS::{0}\n".format(str(results[c_name])))
return results
[docs]def get_hook_type(opts):
"""
Guess the right hook type, gerrit sometimes resolves the real path
:param opts: options given to the script, so it can guess the hook type
"""
if opts.patchset:
return 'patchset-created'
elif opts.author:
return 'comment-added'
elif opts.submitter:
return 'change-merged'
elif opts.abandoner:
return 'change-abandoned'
else:
return os.path.basename(__file__)
[docs]def print_title(title, line_size=40, line_symbol='='):
"""
Printing the title with upper and lower lines with specified
line size and symbol
:param title: title string
:param line_size: line size (default: 40)
:param line_symbol: line symbol (default: '=')
"""
logging.debug(line_symbol * line_size)
logging.debug(title)
logging.debug(line_symbol * line_size)
[docs]def send_summary(change_id, project, results):
"""
Parse all the results and generate the summary review
:param change_id: string to identify the change with, usually the commit
hash or the change-id,patchset pair
:param project: project name (i.e 'ovirt-engine')
:param results: Dictionary with the compiled results for all the chains
It will use the lowest values for the votes, skipping the ones that are
`None`, and merge all the messages into one.
For example, if it has two chains, one with CR:0 and one with CR:1, the
result will be CR:0.
"""
msg = []
code_review = None
verified = None
# Generate final review
for result in results.itervalues():
if result['msg']:
msg.append(result['msg'])
if result['code_review'] is not None \
and (code_review is None
or result['code_review'] < code_review):
code_review = result['code_review']
if result['verified'] is not None \
and (verified is None
or result['verified'] < verified):
verified = result['verified']
# Reset if neutral review
if verified is None:
verified = 0
if code_review is None:
code_review = 0
msg = '\n'.join(msg)
conf = config.load_config()
g_server = gerrit.Gerrit(conf['GERRIT_SRV'])
g_server.review(change_id, project, msg, code_review, verified)
logging.debug("==> FINAL REVIEW::\n" + '#' * 80 + '\n' +
"CR: {0}\nV: {1}\nMSG:\n{2}\n".format(
str(code_review), str(verified), str(msg)) +
'#' * 80 + '\n')
[docs]def main():
logpath = os.path.join(os.path.dirname(__file__), '..', 'logs')
logging.basicConfig(filename=os.path.join(logpath, 'gerrit.hooks.log'),
level=logging.DEBUG,
format='%(asctime)s::'
+ str(os.getpid())
+ '::%(levelname)s::%(message)s')
# Redirect all STDOUT to the logger
stdout_logger = logging.getLogger("STDOUT")
sys.stdout = LoggerWriter(stdout_logger, logging.INFO)
# Redirect all STDERR to the logger
stderr_logger = logging.getLogger("STDERR")
sys.stderr = LoggerWriter(stderr_logger, logging.ERROR)
parser = get_parser()
known_args, rest = parser.parse_known_args()
res = reduce(flatten, vars(known_args).items(), [])
# This second time is to force the existence of the required args
parser.parse_args(res)
if 'GIT_DIR' not in os.environ:
logging.error("Set the GIT_DIR to the repository path")
raise Exception("Set the GIT_DIR to the repository path")
logging.debug('>' * 80 + '\n')
logging.debug("==> RECEIVED PARAMS::{0}\n".format(sys.argv))
if hasattr(known_args, 'kind') and known_args.kind == 'TRIVIAL_REBASE':
logging.debug("==> REBASE ACTION\n")
logging.debug('<' * 80 + '\n')
return
hook_type = get_hook_type(known_args)
print_title("STARTED '{0}'".format(hook_type))
if hook_type == "comment-added":
logging.debug("==> COMMENT ADDED::\n" + '#' * 80 +
"\n{0}\n".format(known_args.comment) + '#' * 80 + '\n')
hooks = \
get_hooks(os.path.join(os.environ['GIT_DIR'], 'hooks'), hook_type)
hooks and hooks.sort()
logging.debug("==> AVAILABLE HOOKS::{0}\n".format(hooks))
tags = {}
change_ident = None
if known_args.commit:
# get tags from the commit message
tags.update(get_commit_tags(known_args.commit))
change_ident = known_args.commit
if change_ident is None and known_args.patchset and known_args.change:
change_ident = "{0},{1}".format(known_args.patchset, known_args.change)
if known_args.comment:
# and from the gerrit comment
tags.update(get_comment_tags(known_args.comment))
for tag_val, fun in tags.itervalues():
# Execute the functions with the tag value, the current available
# hooks, and the args so they can be modified
hooks = fun(tag_val, hooks, known_args)
# if we found any hooks, run them
if hooks:
results = \
run_chains(os.path.join(os.environ['GIT_DIR'], 'hooks'), hooks)
if change_ident and results:
send_summary(change_ident, known_args.project, results)
print_title("FINISHED '{0}'".format(hook_type))
logging.debug('<' * 80 + '\n')
if __name__ == '__main__':
main()