SVN-Hook für das Review Board

Im letzten Artikel ging es um Code-Reviews mit dem Review Board. Ich habe die Vorteile von Code-Reviews und einen möglichen Review-Prozess beschrieben. Außerdem habe ich erwähnt, dass SVN-Commits bei uns automatisch ein Review auslösen. Hier möchte ich die Details zu dieser Integration verraten.

Die Integration in das SVN haben wir mit einem Post-Commit-Hook gelöst, der das Command-Line-Tool post-review startet. Der Hook ist auf zwei Skripte aufgeteilt. Das folgende Shellskript organisiert das Logging und ruft das Pythonskript postcommit-review.py auf:

#!/bin/sh
REPOSITORY="$1"
REV="$2"
LOG=/var/log/post-commit.log

echo `date` "$REPOS" "$REV" >> $LOG

# note the & after the stdout/stderr redirection which starts the process in the background - 
#        we don't want that the commiter has to wait
/path/to/postcommit-review.py "$REPOS" "$REV" >> $LOG 2>> $LOG &

exit 0

Es ist auch möglich den Commit fehlschlagen zu lassen, wenn die Anlage im Review Board schiefgeht. Dazu darf postcommit-review.py nicht im Hintergrund gestartet werden, sondern mit postcommit-review.py .. || exit 1

Die Logdatei wird mit logrotate regelmäßig rotiert, um den Speicherverbrauch der Logs zu limitieren.

Das Python-Skript postcommit-review.py informiert das Review Board über den neuen Commit undbasiert auf https://github.com/reviewboard/reviewboard/blob/master/contrib/tools/svn…. Für das Skript gibt es einen eigenen User im Review Board, der ‘submit as’-Rechte hat, damit er im Namen des Committers den Review-Request erstellen kann. Für alle Commits ohne Angabe eines Reviewers werden Review-Requests in der Gruppe noreview erstellt, falls sie doch jemand ansehen möchte. Falls die Anlage des Review-Requests nicht klappt, wird ein zweiter Versuch ohne Daten aus der Commit-Message gemacht und der Review-Request in der Gruppe error erstellt. Diese Gruppe wurde zuvor im Review Board erstellt.

#!/usr/bin/env python
#
# This script should be invoked from the subversion post-commit hook like this:
#
# REPOS="$1"
# REV="$2"
# /usr/bin/python /some/path/svn-hook-postcommit-review "$REPOS" "$REV" || exit 1
#
# Searches the commit message for text in the form of:
# reviewer:[REVIEW-GROUP-NAME]
# updatereview:[REVIEW-NR]
#
# The log message is interpreted for review request parameters:
# summary = up to first period+space, first new-line, or 250 chars
# description = entire log message
# existing review updated if log message includes 'updatereview:[0-9]+'
# bugs added to review if log message includes 'bug:[0-9]+'
#
# By default, the review request is created out of a diff between the current
# revision (M) and the previous revision (M-1).
#
# To limit the diff to changes in a certain path (e.g. a branch), include
# 'base path:"<path>"' in the log message. The path must be relative to
# the root of the repository and be surrounded by single or double quotes.
#
# An example commit message is:
# Changed blah and foo to do this or that, bug:1234. reviewer:christian
#
# This would create a review of the currently commited revision for the 
# review board user 'christian'. It would place the entire log message in the 
# review summary and description, and put bug id 1234 in the bugs field.
#
# This script may only be run from outside a working copy.
#

#
# User configurable variables
#

# Path to post-review script
POSTREVIEW_PATH = "/usr/local/bin/"
# Username and password for Review Board user that will be connecting
# to create all review requests. This user must have 'submit as'
# privileges, since it will submit requests in the name of svn committers.
USERNAME = 'commit-hook'
PASSWORD = 'xxxxxxxxxxxxxxxxx'

# If true, runs post-review in debug mode and outputs its diff
DEBUG = False

#
# end user configurable variables
#

import sys
import os
import subprocess
import re
import svn.fs
import svn.core
import svn.repos

# starts a sub process
def execute(command, env=None, ignore_errors=False):
    """
    Utility function to execute a command and return the output.
    Derived from Review Board's post-review script.
    """
    if env:
        env.update(os.environ)
    else:
        env = os.environ

    p = subprocess.Popen(command,
                         stdin = subprocess.PIPE,
                         stdout = subprocess.PIPE,
                         stderr = subprocess.STDOUT,
                         shell = False,
                         close_fds = sys.platform.startswith('win'),
                         universal_newlines = True,
                         env = env)
    data = p.stdout.read()
    rc = p.wait()
    if rc and not ignore_errors:
        sys.stderr.write('Failed to execute command: %s\n%s\n' % (command, data))
    return rc

def main():
    if len(sys.argv) != 3:
        sys.stderr.write('Usage: %s <repos> <rev>\n' % sys.argv[0])
        sys.exit(1)

    repos = sys.argv[1]
    rev = sys.argv[2]

    # verify that rev parameter is an int
    try:
        int(rev)
    except ValueError:
        sys.stderr.write("Parameter <rev> must be an int, was given %s\n" % rev)
        sys.exit(1)

    # get the svn file system object
    fs_ptr = svn.repos.svn_repos_fs(svn.repos.svn_repos_open(
            svn.core.svn_path_canonicalize(repos)))

    # get the log message
    log = svn.fs.svn_fs_revision_prop(fs_ptr, int(rev),
                                    svn.core.SVN_PROP_REVISION_LOG)

    # we set this to true if there is no review request
    # in that case we assign the review-request to the noreview-group
    noreview = False

    # error if log message is blank
    emptylog = len(log.strip()) < 1
    if emptylog:
        print 'Log message is empty -> noreview = True'
        noreview = True

    # get the author
    author = svn.fs.svn_fs_revision_prop(fs_ptr, int(rev),
                                       svn.core.SVN_PROP_REVISION_AUTHOR)

    # error if author is blank
    if len(author.strip()) < 1:
        sys.stderr.write("Author is blank, no review request created\n")
        sys.exit(1)

    # check whether to create a review, based on presence of some key words
    if (not 'reviewer:' in log) and (not 'updatereview:' in log):
        print 'No review requested -> noreview = True'
        noreview = True

    # check for update to existing review
    m = re.search(r'updatereview:([0-9]+)', log, re.M | re.I)
    if m:
        reviewid = '--review-request-id=' + m.group(1)
    else:
        reviewid = ''

    # get previous revision number -- either 1 prior, or
    # user-specified number
    m = re.search(r'after(?: )?revision:([0-9]+)', log, re.M | re.I)
    if m:
        prevrev = m.group(1)
    else:
        prevrev = int(rev) - 1

    # check for an explicitly-provided base path (must be contained
    # within quotes)
    m = re.search(r'base ?path:[\'"]([^\'"]+)[\'"]', log, re.M | re.I)
    if m:
        base_path = m.group(1)
    else:
        base_path = ''

    # summary is log up to first period+space / first new line / first 250 chars
    # (whichever comes first)
    if emptylog:
       summary = '--summary=NO COMMIT MESSAGE'
    else:
       summary = '--summary=' + log[:250].splitlines().pop(0).split('. ').pop(0)

    # other parameters for postreview
    repository_url = '--repository-url=file://' + repos
    password = '--password=' + PASSWORD
    username = '--username=' + USERNAME
    description = "--description=(In [%s]) %s" % (rev, log)
    submitas = '--submit-as=' + author
    publish = '-p'
    revision = '--revision-range=%s:%s' % (prevrev, rev)

    # override the settings
    server = '--server=http://my.reviewboard.domain/reviewboard/'
    # check if there is a target group
    m = re.search(r'(?:reviewer):([-a-z,_]+)', log, re.M | re.I)
    if m:
        target = '--target-groups=' + m.group(1)
    else:
        target = ''
    # if there is no review request, put the review request in the group noreview
    if noreview:
        target = '--target-groups=noreview'
    # link to bug
    m = re.search(r'bug[: ]([,0-9]+)', log, re.M | re.I)
    if m:
        bugs = '--bugs-closed=' + m.group(1)
    else:
        bugs = ''

    # common arguments
    args = [repository_url, username, password, publish,
            submitas, revision, base_path, reviewid, server, target]

    # if not updating an existing review, add extra arguments 
    # (we do not 'override' existing reviews)
    if len(reviewid) == 0:
        args += [summary, description, bugs]

    # filter out any potentially blank args, which will confuse post-review
    args = [i for i in args if len(i) > 1]

    if DEBUG:
        args += ['-d', '--output-diff']
        print [os.path.join(POSTREVIEW_PATH, 'post-review')] + args

    # Run Review Board post-review script
    rc = execute([os.path.join(POSTREVIEW_PATH, 'post-review')] + args,
                   env = {'LANG': 'en_US.UTF-8'})

    # if the call failed - for whatever reason (maybe the target group does not exist) -
    # we try to create a default review request for the group 'error'
    if rc:
        print 'first attempt failed, trying again without data from commit message'
        args = [repository_url, username, password, publish,
                submitas, revision, server, "--summary=AUTO-CREATE r%s" % (rev), 
                "--description=revision%s" % (rev), '--target-groups=error']
        rc = execute([os.path.join(POSTREVIEW_PATH, 'post-review')] + args,
                env = {'LANG': 'en_US.UTF-8'})
        if rc:
            print 'second attempt failed too, giving up'
        else:
            print 'second attempt succeeded'



if __name__ == '__main__':
    main()

Alternativ zu post-review kann auch das post-Kommando der RBTools verwendet werden.

Viel Erfolg damit!

Andreas Hubmer
(Software Architect)