Source code for hook_dispatcher

#!/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_tags(tags_def, text): """ Retrieves all the tags defined in the tag_def from text :param tags_def: Definition of the tags as specified above :param text: text to look for tags (commit message, gerrit message...) """ result = {} for line in text.splitlines(): for tname, toptions in tags_def.iteritems(): if line.startswith(toptions[0]): result[tname] = (line[len(toptions[0]):], toptions[1]) return result
[docs]def get_commit_tags(commit): """ Get the tags that were specified in the comit message :param commit: Commit to get the message from """ repo = Repo(os.environ['GIT_DIR']) message = repo[commit].message logging.info("==> COMMIT MESSAGE::\n" + "#" * 80 + "\n{0}".format(message) + "#" * 80 + "\n") return get_tags(COMMIT_TAGS, message)
[docs]def get_comment_tags(comment): """ Get the tags that were specified in gerrit message :param comment: Gerrit comment """ comment = str(comment) logging.debug("==> GERRIT COMMENT::\n" + comment) return get_tags(COMMENT_TAGS, comment)
[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 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()