From c0abda84b7b456de746ff4431a2f6a0066b5cc63 Mon Sep 17 00:00:00 2001 From: Devendra Gera Date: Sun, 7 Mar 2010 03:35:51 +0530 Subject: [PATCH] Initial gitzilla commit. --- MANIFEST.in | 4 ++ Makefile | 82 +++++++++++++++++++++++ README | 41 ++++++++++++ __init__.py | 59 +++++++++++++++++ etc/gitzillarc | 90 +++++++++++++++++++++++++ hooks.py | 173 +++++++++++++++++++++++++++++++++++++++++++++++++ hookscripts.py | 138 +++++++++++++++++++++++++++++++++++++++ setup.py | 29 +++++++++ utils.py | 92 ++++++++++++++++++++++++++ 9 files changed, 708 insertions(+) create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README create mode 100644 __init__.py create mode 100644 etc/gitzillarc create mode 100644 hooks.py create mode 100644 hookscripts.py create mode 100644 setup.py create mode 100644 utils.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c033674 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include *.txt +include *.py +recursive-include etc * + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c163c94 --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ + +NAME=gitzilla +PY2DSC=$(shell which py2dsc) +BUILDCMD=$(shell which dpkg-buildpackage) +DEPENDENCIES=python-setuptools +BUILDOPTS= -rfakeroot -uc -us +VERSION:=$(shell perl -ne 'print $$1 if /version=.(\S+).,/' setup.py) +ETC_DIR=/etc + +define CONFIG_REPLACEMENT_STUB +binary: build \ +\n\tdh install --until dh_install\ +\n\tmkdir -p debian/python-${NAME}${ETC_DIR}\ +\n\tcp etc/* debian/python-${NAME}${ETC_DIR}\ +\n\tdh install --after dh_install\ +\n\tdh binary +endef + +DEPS_REPLACEMENT:="s/$$/, ${DEPENDENCIES}/ if /^Depends: /" +CONF_REPLACEMENT:="s(binary: build.*$$)(${CONFIG_REPLACEMENT_STUB})" + +.PHONY: check-prerequisites deb debianize-source edit-source build clean bumpversion + +deb: check-prerequisites build + $(info collecting packages ...) + @mkdir -p debian + @mv ${TARGETDIR}/deb_dist/python-${NAME}*.deb debian/ + @mv ${TARGETDIR}/deb_dist/*.orig.tar.gz debian/ + @mv ${TARGETDIR}/deb_dist/*.diff.gz debian/ + @mv ${TARGETDIR}/deb_dist/*.dsc debian/ + @rm -rf ${TARGETDIR} + +dist/${NAME}-${VERSION}.tar.gz: setup.py + $(info preparing source distribution ...) + @python setup.py sdist + @rm -rf ${NAME}.egg-info + +debianize-source: dist/${NAME}-${VERSION}.tar.gz + $(info debianizing ${NAME} via py2dsc ...) + @cd ${TARGETDIR} ; \ + cp ${CURDIR}/dist/${NAME}-${VERSION}.tar.gz . ; \ + ${PY2DSC} *.tar.gz ; + +edit-source: debianize-source + $(info changing build dependencies for ${NAME} ...) + @cd ${TARGETDIR}/deb_dist/${NAME}-${VERSION} ; \ + perl -pi -e 's/python-all-dev/python-dev/' debian/control ; \ + perl -pi -e ${DEPS_REPLACEMENT} debian/control ; \ + BUILD_STR=`grep 'binary: build' debian/rules 2>/dev/null` ; \ + if [ x"$$BUILD_STR" == x ]; then \ + echo -e "${CONFIG_REPLACEMENT_STUB}" >> debian/rules ; \ + else \ + perl -pi -e ${CONF_REPLACEMENT} debian/rules ; \ + fi + +build: edit-source + $(info building ${NAME} version ${VERSION} ...) + @cd ${TARGETDIR}/deb_dist/${NAME}-${VERSION} ; \ + ${BUILDCMD} ${BUILDOPTS} + +check-prerequisites: +ifeq ($(PY2DSC), ) + $(error no py2dsc found) +else ifeq ($(BUILDCMD), ) + $(error no dpkg-buildpackage found) +else ifeq ($(VERSION), ) + $(error could not determine version from setup.py) +else +TARGETDIR:=$(shell mktemp -d) +endif + + +clean: + @rm -rf ${NAME}.egg-info + @rm -rf dist + @rm -rf ${TARGETDIR} + @rm -rf debian + +bumpversion: + @${EDITOR} setup.py + + diff --git a/README b/README new file mode 100644 index 0000000..06de339 --- /dev/null +++ b/README @@ -0,0 +1,41 @@ + +GitZilla +======== + +GitZilla is Python magic to support Git-Bugzilla integration. There are +various ways of using GitZilla. + +Note that GitZilla must be installed on the machine receiving commits from +everyone - home to the the "official" or the "central" repository. + +Simple ready scripts +-------------------- + +To quickly start using GitZilla: + + * Install GitZilla. You may choose the .deb for easy installation on + Debian/Ubuntu systems. Otherwise, just unpack the source and install in + the usual setuptools way: + + sudo python setup.py install + + * Switch to the hooks directory (/path/to/repository/.git/hooks) and delete + the 'post-receive' and 'update' hooks. + + * Link (or copy) the gitzilla provided hooks: + + ln -s $(which gitzilla-post-receive) post-receive + ln -s $(which gitzilla-update) update + + * Read and edit the config file at /etc/gitzillarc + + * Commit away! + + +Custom GitZilla +--------------- + +Coming soon. Till then, import the modules gitzilla and gitzilla.hooks in a +python interactive shell and dooc at the module help. + + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..94fccc6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,59 @@ + +""" +GitZilla + +Git-Bugzilla integration in a Python module. + +Requirements +------------ + + - Python (tested with 2.6, should work with >= 2.5) + - pybugz (tested with 0.8.0) + +pybugs can be obtained from http://github.com/ColdWind/pybugz/downloads + +""" + +__version__ = '1.0' +__author__ = 'Devendra Gera ' +__license__ = """Copyright 2010, Devendra Gera , +All rights reserved. + +This is Free Software, released under the terms of the GNU General Public +License, version 3. A copy of the license can be obtained by emailing the +author, or from http://www.gnu.org/licenses/gpl-3.0.html + +As noted in the License, this software does not come with any warranty, +explicit or implied, to the extent permissible by law. +This program might, and would be buggy. Use it at your own risk. +""" + +sDefaultSeparator = "~.~.~.~.~.~.~.~.~.~.~.~.~.~.~.~." + +sDefaultFormatSpec = """ +commit %H +parents %P +Author %aN (%aE) +Date %aD +Commit By %cN (%cE) +Commit Date %cD + +%s + +%b +""".replace("\n", "%n") + +import re + +oDefaultBugRegex = re.compile(r"bug\s*(?:#|)\s*(?P\d+)", + re.MULTILINE | re.DOTALL | re.IGNORECASE) + +import logging + +class NullHandler(logging.Handler): + def emit(self, record): + pass + +NullLogger = logging.getLogger("gitzilla") +NullLogger.addHandler(NullHandler()) + diff --git a/etc/gitzillarc b/etc/gitzillarc new file mode 100644 index 0000000..1d1d771 --- /dev/null +++ b/etc/gitzillarc @@ -0,0 +1,90 @@ + +# The gitzilla config file. +# This file is in the ConfigParser format. This is something like: +# +# [Section_Name] +# item: value +# another_item: value2 +# + + +# Each git repository should have it's own section. The global config +# at /etc/gitzillarc MUST specify bugzilla_url, bugzilla_user and +# bugzilla_password +# +# The bugzilla_user and bugzilla_password may be overridden by user +# specific config files at ~/.gitzillarc +# +# Note that the global config would be readable by all and is required +# to contain a username/password for bugzilla. If you think this is a +# problem, you could put in dummy values. In that case however, users +# MUST put their own credentials in their ~/.gitzillarc files if the +# update hook is being used with the allowed_bug_states option set. If +# that is not the case, users can get away by not having their +# credentials available, and their commits would not show up in bugziila +# comments. +# +# The format of the user specific files is the same, and they must +# have a section for each repository to be configured. +# +# Mandatory values for /etc/gitzillarc: +# +# * bugzilla_url +# * bugzilla_user +# * bugzilla_password +# +# +# Optional values for /etc/gitzillarc: +# +# * allowed_bug_states +# +# a comma separated set of states that a bug must be in, in order +# for the commit to be allowed by the update hook. If this is set, +# working bugzilla credentials are required. +# +# * formatspec +# +# appended to '--format=format:' in 'git whatchanged'. See the +# 'git whatchanged' manpage for more info. +# +# * separator +# +# a string which would never occur in commit messages. You should +# not need to set this, as it is already at a dafe default. +# +# * bug_regex +# +# the (Python) regex for capturing bug numbers. MUST capture all +# the digits of the bug id in a named group called 'bug'. This +# regex is compiled internally with the MULTILINE, DOTALL, and +# IGNORECASE options set. The default regex captures the +# following forms: +# - bug 123 +# - Bug # 123 +# - BUG123 +# - bug# 123 +# - Bug #123 +# +# * logfile +# +# the file to log to. Must be writable by the uid of the git +# process. In case of ssh pushes, it usually means that it should +# be writable by all. +# +# * loglevel +# +# can be 'info' or 'debug' - defaults to 'debug'. +# +# +# +# The user specific files are entirely optional. The only values +# they override are bugzilla_user and bugzilla_password. +# + +# sample configuration: +# +# [/path/to/repository/.git] +# bugzilla_url: https://repo.example.com/bugzilla/ +# bugzilla_user: foo@example.com +# bugzilla_password: barbarblah + diff --git a/hooks.py b/hooks.py new file mode 100644 index 0000000..8fab1d9 --- /dev/null +++ b/hooks.py @@ -0,0 +1,173 @@ + +""" +hooks - git hooks provided by gitzilla. + +""" + +import re +import sys +from utils import get_changes, post_to_bugzilla, get_bug_status, notify_and_exit +from gitzilla import sDefaultSeparator, sDefaultFormatSpec, oDefaultBugRegex +from gitzilla import NullLogger +import bugz.bugzilla +import traceback + + +def post_receive(sBZUrl, sBZUser, sBZPasswd, sFormatSpec=None, oBugRegex=None, sSeparator=None, logger=None): + """ + a post-recieve hook handler which extracts bug ids and adds the commit + info to the comment. If multiple bug ids are found, the comment is added + to each of those bugs. + + sBZUrl is the base URL for the Bugzilla installation. + + oBugRegex specifies the regex used to search for the bug id in the commit + messages. It MUST provide a named group called 'bug' which contains the bug + id (all digits only). If oBugRegex is None, a default bug regex is used, + which is: + + r"bug\s*(?:#|)\s*(?P\d+)" + + This matches forms such as: + - bug 123 + - bug #123 + - BUG # 123 + - Bug#123 + - bug123 + + The format spec is appended to "--format=format:" and passed to + "git whatchanged". See the git whatchanged manpage for more info on the + format spec. + + If sFormatSpec is None, a default format spec is used. + + The separator is a string that would never occur in a commit message. + If sSeparator is None, a default separator is used, which should be + good enough for everyone. + + If a logger is provided, it would be used for all the logging. If logger + is None, logging will be disabled. The logger must be a Python + logging.Logger instance. + """ + if sFormatSpec is None: + sFormatSpec = sDefaultFormatSpec + + if sSeparator is None: + sSeparator = sDefaultSeparator + + if oBugRegex is None: + oBugRegex = oDefaultBugRegex + + if logger is None: + logger = NullLogger + + sPrevRev = None + for sLine in iter(sys.stdin.readline, ""): + (sOldRev, sNewRev, sRefName) = sLine.split(" ") + if sPrevRev is None: + sPrevRev = sOldRev + logger.debug("oldrev: '%s', newrev: '%s'", (sOldRev, sNewRev)) + asChangeLogs = get_changes(sOldRev, sNewRev, sFormatSpec, sSeparator) + + for sMessage in asChangeLogs: + logger.debug("Considering commit:\n%s", (sMessage,)) + oMatch = re.search(oBugRegex, sMessage) + if oMatch is None: + logger.info("Bug id not found in commit:\n%s", (sMessage,)) + continue + for oMatch in re.finditer(oBugRegex, sMessage): + iBugId = int(oMatch.group("bug")) + logger.debug("Found bugid %d", (iBugId,)) + try: + post_to_bugzilla(iBugId, sMessage, sBZUrl, sBZUser, sBZPasswd) + except Exception, e: + logger.exception("Could not add comment to bug %d", (iBugId,)) + + + +def update(oBugRegex=None, asAllowedStatuses=None, sSeparator=None, sBZUrl=None, sBZUser=None, sBZPasswd=None, logger=None): + """ + an update hook handler which rejects commits without a bug reference. + This looks at the sys.argv array, so make sure you don't modify it before + calling this function. + + oBugRegex specifies the regex used to search for the bug id in the commit + messages. It MUST provide a named group called 'bug' which contains the bug + id (all digits only). If oBugRegex is None, a default bug regex is used, + which is: + + r"bug\s*(?:#|)\s*(?P\d+)" + + This matches forms such as: + - bug 123 + - bug #123 + - BUG # 123 + - Bug#123 + - bug123 + + asAllowedStatuses is an array containing allowed statuses for the found + bugs. If a bug is not in one of these states, the commit will be rejected. + If asAllowedStatuses is None, status checking is diabled. + + The separator is a string that would never occur in a commit message. + If sSeparator is None, a default separator is used, which should be + good enough for everyone. + + If status checking is enabled, sBZUrl specifies the base URL for the + Bugzilla installation. + + If a logger is provided, it would be used for all the logging. If logger + is None, logging will be disabled. The logger must be a Python + logging.Logger instance. + """ + if oBugRegex is None: + oBugRegex = oDefaultBugRegex + + if sSeparator is None: + sSeparator = sDefaultSeparator + + if logger is None: + logger = NullLogger + + sFormatSpec = sDefaultFormatSpec + + if asAllowedStatuses is not None: + # sanity checking + for item in (sBZUrl, sBZUser): + if item is None: + raise ValueError("Bugzilla info required for status checks") + + # create and cache bugzilla instance + oBZ = bugz.bugzilla.Bugz(sBZUrl, user=sBZUser, password=sBZPasswd) + + (sOldRev, sNewRev) = sys.argv[2:4] + logger.debug("oldrev: '%s', newrev: '%s'", (sOldRev, sNewRev)) + + asChangeLogs = get_changes(sOldRev, sNewRev, sFormatSpec, sSeparator) + + for sMessage in asChangeLogs: + logger.debug("Checking for bug refs in commit:\n%s", (sMessage,)) + oMatch = re.search(oBugRegex, sMessage) + if oMatch is None: + logger.error("No bug ref found in commit:\n%s", (sMessage,)) + print "No bug ref found in commit:\n%s" % (sMessage,) + sys.exit(1) + else: + if asAllowedStatuses is not None: + # check all bug statuses + for oMatch in re.finditer(oBugRegex, sMessage): + iBugId = int(oMatch.group("bug")) + logger.debug("Found bug id %d", (iBugId,)) + try: + sStatus = get_bug_status(oBZ, iBugId) + if sStatus is None: + notify_and_exit("Bug %d does not exist" % (iBugId,)) + except Exception, e: + logger.exception("Could not get status for bug %d", (iBugId,)) + notify_and_exit("Could not get staus for bug %d" % (iBugId,)) + + logger.debug("status for bug %d is %s", (iBugId, sStatus)) + if sStatus not in asAllowedStatuses: + logger.info("Cannot accept commit for bug %d in state %s", (iBugId, sStatus)) + notify_and_exit("Bug %d['%s'] is not in %s" % (iBugId, sStatus, asAllowedStatuses)) + diff --git a/hookscripts.py b/hookscripts.py new file mode 100644 index 0000000..4735759 --- /dev/null +++ b/hookscripts.py @@ -0,0 +1,138 @@ + +""" +hookscripts - ready to use hook scripts for gitzilla. + +These pick up configuration values from the environment. + +""" + +import os +import sys +import gitzilla.hooks +import logging +import ConfigParser + +def get_or_default(conf, section, option, default=None): + if conf.has_option(section, option): + return conf.get(section, option) + return None + + +def get_bz_data(siteconfig, userconfig): + sRepo = os.getcwd() + + try: + sBZUrl = siteconfig.get(sRepo, "bugzilla_url") + sBZUser = siteconfig.get(sRepo, "bugzilla_user") + sBZPasswd = siteconfig.get(sRepo, "bugzilla_password") + except: + print "missing/incomplete bugzilla conf" + sys.exit(1) + + if userconfig.has_section(sRepo): + if userconfig.has_option(sRepo, "bugzilla_user") and \ + userconfig.has_option(sRepo, "bugzilla_password"): + sBZUser = userconfig.get(sRepo, "bugzilla_user") + sBZPasswd = userconfig.get(sRepo, "bugzilla_password") + + return (sBZUrl, sBZUser, sBZPasswd) + + + +def get_logger(siteconfig): + sRepo = os.getcwd() + logger = None + if siteconfig.has_option(sRepo, "logfile"): + logger = logging.getLogger("gitzilla") + logger.addHandler(logging.FileHandler(siteconfig.get(sRepo, "logfile"))) + # default to debug, but switch to info if asked. + sLogLevel = get_or_default(siteconfig, sRepo, "loglevel", "debug") + logger.setLevel({"info": logging.INFO}.get(sLogLevel, logging.DEBUG)) + + return logger + + +def get_bug_regex(siteconfig): + sRepo = os.getcwd() + oBugRegex = None + if siteconfig.has_option(sRepo, "bug_regex"): + oBugRegex = re.compile(siteconfig.get(sRepo, "bug_regex"), + re.MULTILINE | re.DOTALL | re.IGNORECASE) + + return oBugRegex + + + + +def post_receive(): + """ + The gitzilla-post-receive hook script. + + The configuration is picked up from /etc/gitzillarc and ~/.gitzillarc + + The user specific configuration is allowed to override the bugzilla + username and password. + """ + siteconfig = ConfigParser.SafeConfigParser() + siteconfig.readfp(file("/etc/gitzillarc")) + sRepo = os.getcwd() + + if not siteconfig.has_section(sRepo): + print "No %s section found in /etc/gitzillarc" % (sRepo,) + sys.exit(1) + + userconfig = ConfigParser.SafeConfigParser() + userconfig.read(os.path.expanduser("~/.gitzillarc")) + + (sBZUrl, sBZUser, sBZPasswd) = get_bz_data(siteconfig, userconfig) + + logger = get_logger(siteconfig) + oBugRegex = get_bug_regex(siteconfig) + sSeparator = get_or_default(siteconfig, sRepo, "separator") + sFormatSpec = get_or_default(siteconfig, sRepo, "formatspec") + + gitzilla.hooks.post_receive(sBZUrl, sBZUser, sBZPasswd, sFormatSpec, + oBugRegex, sSeparator, logger) + + + +def update(): + """ + The gitzilla-update hook script. + + The configuration is picked up from /etc/gitzillarc and ~/.gitzillarc + + The user specific configuration is allowed to override the bugzilla + username and password. + """ + siteconfig = ConfigParser.SafeConfigParser() + siteconfig.readfp(file("/etc/gitzillarc")) + sRepo = os.getcwd() + + if not siteconfig.has_section(sRepo): + print "No %s section found in /etc/gitzillarc" % (sRepo,) + sys.exit(1) + + logger = get_logger(siteconfig) + oBugRegex = get_bug_regex(siteconfig) + sSeparator = get_or_default(siteconfig, sRepo, "separator") + sFormatSpec = get_or_default(siteconfig, sRepo, "formatspec") + sBZUrl = None + sBZUser = None + sBZPasswd = None + + asAllowedStatuses = None + if siteconfig.has_option(sRepo, "allowed_bug_states"): + asAllowedStatuses = map(lambda x: x.strip(), + siteconfig.get(sRepo, "allowed_bug_states").split(",")) + + # and then we need the bugzilla info as well. + userconfig = ConfigParser.SafeConfigParser() + userconfig.read(os.path.expanduser("~/.gitzillarc")) + (sBZUrl, sBZUser, sBZPasswd) = get_bz_data(siteconfig, userconfig) + + + gitzilla.hooks.update(oBugRegex, asAllowedStatuses, sSeparator, + sBZUrl, sBZUser, sBZPasswd, logger) + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a3de3c3 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +# fix broken behaviour - hardlinks are a FS attribute, not an OS attribute +# without doing this, the builds fail on encfs, AFS, NFS etc. +import os +if hasattr(os, 'link'): + delattr(os, 'link') + +from setuptools import setup +args = dict( + name='gitzilla', + description='Git-Bugzilla integration', + author='Devendra Gera', + author_email='gera@theoldmonk.net', + url='http://www.theoldmonk.net/gitzilla/', + version='1.0', + requires=['pybugz'], + package_dir={'gitzilla': '.'}, + packages=['gitzilla'], + package_data={'': ['etc/*']}, + include_package_data=True, + entry_points={ + 'console_scripts': [ + 'gitzilla-post-receive = gitzilla.hookscripts:post_receive', + 'gitzilla-update = gitzilla.hookscripts:update', + ], + } +) + +setup(**args) + diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..73a66f7 --- /dev/null +++ b/utils.py @@ -0,0 +1,92 @@ + +""" +utils module for gitzilla + +""" + +import os +import sys +import subprocess +import bugz.bugzilla + + +def execute(asCommand, bSplitLines=False, bIgnoreErrors=False): + """ + Utility function to execute a command and return the output. + """ + p = subprocess.Popen(asCommand, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=False, + close_fds=True, + universal_newlines=True, + env=None) + if bSplitLines: + data = p.stdout.readlines() + else: + data = p.stdout.read() + iRetCode = p.wait() + if iRetCode and not bIgnoreErrors: + print >>sys.stderr, 'Failed to execute command: %s\n%s' % (command, data) + sys.exit(-1) + + return data + + + +def get_changes(sOldRev, sNewRev, sFormatSpec, sSeparator): + """ + returns an array of chronological changes, between sOldRev and sNewRev, + according to the format spec sFormatSpec. + """ + sChangeLog = execute(["git", "whatchanged", + "--format=format:%s%s" % (sSeparator, sFormatSpec), + "%s..%s" % (sOldRev, sNewRev)]) + asChangeLogs = sChangeLog.split(sSeparator) + asChangeLogs.reverse() + + return asChangeLogs[:-1] + + + +def post_to_bugzilla(iBugId, sComment, sBZUrl, sBZUser, sBZPasswd): + """ + posts the comment to the given bug id. + """ + for item in (sBZUrl, sBZUser, sBZPasswd): + if item is None: + raise ValueError("Bad bugzilla info") + + oBZ = bugz.bugzilla.Bugz(sBZUrl, user=sBZUser, password=sBZPasswd) + oBZ.modify(iBugId, comment=sComment) + + + +def get_bug_status(oBugz, iBugId): + """ + given the bugz.bugzilla.Bugz instance and the bug id, returns the bug + status. + """ + oBug = oBugz.get(iBugId) + if oBug is None: + return None + return oBug.getroot().find("bug/bug_status").text + + +def notify_and_exit(sMsg): + """ + notifies the error and exits. + """ + print """ + +====================================================================== +Cannot accept commit. + +%s + +====================================================================== + +""" % (sMsg,) + sys.exit(1) +