rszalski
8/3/2010 - 12:00 PM

Post-Commit Hook for GIT - Used with Fixx Issue Tracker. Allows for automatic resolving, closing and commenting issues when commiting.

Post-Commit Hook for GIT - Used with Fixx Issue Tracker. Allows for automatic resolving, closing and commenting issues when commiting.

#!E:\Python31\python
# Created By: Radosław Szalski <radoslaw.szalski@gmail.com>

#   Tested with Python 3.1, git v. 1.7.1 (using msysgit) and Fixx 1.9
#   Rewriting to Python 2.X should be straightforward 
#   Read more - http://docs.python.org/py3k/whatsnew/3.1.html

# This hook script is called after a commit is made
# It's purpose is to update FIXX's (bug tracker) issues when they are fixed/resolved/commented

# Depending on HOW you format your commit message it does different things
#   you can:
#       post only comment to issue
#       resolve issue & post comment
#       resolve issue & post comment & close issue

# I use Fixx not only for bug tacking, but for general issues/task tracking
#   thus I often supply 'Fixed' as a Resolution but also 'Done' etc.
#       you probably won't resolve issues as 'Won't fix' or 'Not a bug' directly from git,
#       nonetheless you are free to use whatever resolution names you have defined

# For the script to work your commit message needs to contain (at least) [#{num}] at the beginning
#   This will only post a comment to an issue
#   example: git commit -m '[#666] some message here'
# To resolve an issue you supply the resolution name before #{num}
#	example: git commit -m '[Fixed #666] some message here'
# To resolve & close an issue you add 'c' right after #{num}
#   example: git commit -m '[Fixed #666c] some msg here'
# Note that comments are always posted

# You can supply any valid resolution name that is set for your project
# they can be lowercase or upper case, you can put spaces before and after a resolution
#   but note, that resolution itself has to be intact
#   example: '[   Won't Fix   #666] msg'
# Recommendation: use only 1 space to separate Resolution Name from issue ID

import re
import subprocess
# I find httplib2 easier to use than Python's urllib
# You can get it here - http://code.google.com/p/httplib2/
import httplib2
import json

# CONFIGURATION - change those

#   {REMEMBER TO CHANGE THE SHEBANG TO REFLECT YOUR ENVIRONMENT}

#   you can get project's ID by going to Projects->{Project} in Fixx and checking the URI
#   example: http://localhost:9000/projects/2

FIXX_URI    = 'http://localhost:9000'   # (no trailing '/') Address of the Fixx installation, this one is an example
PROJECT_ID  = 2
USERNAME    = 'username'
PASSWORD    = 'password'

git     = subprocess.Popen(['git', 'log', '-1', '--pretty=format:"%h %s |author|%cn|date|%ci"'], stdout = subprocess.PIPE)
# The response is: "{short-hash} {commit_msg} |author|{author}|date|{date}"
resp    = git.stdout.read()[1:-2]       # Removing unnecessary characters
# TODO verbose
pID     = re.compile(r'(?P<hash>\w+)\s\[(?P<resolution>.*)\s?#(?P<id>\d+)(?P<close>c|C)?.*\](?P<msg>.*)\|author\|(?P<author>\w+)\|date\|(?P<date>.*)')
matches = pID.search(resp.decode('utf-8'))

# Don't bother if it doesn't match
if matches is not None:
	gitLog = {}
	gitLog['issueResolution']   = matches.group('resolution').lower().strip()
	gitLog['issueID']           = matches.group('id').strip()
	gitLog['commitHash']        = matches.group('hash').strip()
	gitLog['commitMsg']         = matches.group('msg').strip()
	gitLog['commitAuthor']      = matches.group('author').strip()
	gitLog['commitDate']        = matches.group('date').strip()

	if matches.group('close') and matches.group('close').lower() == 'c':
		gitLog['close'] = True
	else:
		gitLog['close'] = False

	h = httplib2.Http()
	h.follow_all_redirects = True
	h.add_credentials(USERNAME, PASSWORD)

	# For some weird reason, even though we supply credentials with add_credentials
	#   we still need to send an Authorization header, even if it's a dummy/fake one
	#   This probably is a bug in python 3.1 (because it happened when I used urllib as well)

	# This is sent when we retrieve project resolutions
	#   because JSON is easier to parse than XML
	headersJSON = {
		'Accept': 'application/json',
		'Content-type': 'application/json',
		"Authorization": "Basic fake"
	}
	# Used for every other request
	headersXML = {
		'Accept': 'application/xml',
		'Content-type': 'application/xml',
		"Authorization": "Basic fake"
	}

	# The commentary that is attached to issues in Fixx
	comment = """
	<comment>
		<text>
			Resolution set to: {resolution}

			Commit: {hash}
			Commiter: {author}
			Date: {date}
			Message: {msg}
		</text>
	</comment>
	""".format(resolution   = gitLog['issueResolution'],
					hash    = gitLog['commitHash'],
					author  = gitLog['commitAuthor'],
					date    = gitLog['commitDate'],
					msg     = gitLog['commitMsg'])

	# We're getting an issue with EVERY setting specified
	#   This is VERY important, since later we use PUT request to update the issue,
	#   And if we do not supply every possible Issue setting - those missing get reverted to defaults
	#   See API documentation for Fixx
	resp, issue = h.request("{uri}/api/issues/{id}".format(uri = FIXX_URI, id = gitLog['issueID']), "GET", headers = headersXML)
	issue = issue.decode('utf-8')

	# We retrieve resolutions for current project, and parse them into a dictionary
	def getResolutions(projectID):
		resp, resolutions = h.request("{uri}/api/projects/{id}/resolutions".format(uri= FIXX_URI, id = projectID), "GET", headers = headersJSON)
		decoder = json.JSONDecoder()
		resolutionsJSON = decoder.decode(resolutions.decode("utf-8"))

		resolutionsDict = {}
		for res in resolutionsJSON["resolution"]:
			resolutionsDict[res['name'].lower()] = res['id']
		return resolutionsDict

	# We're replacing only certain settings in Issue, others are not touched and preserved!
	def resolveIssue(issue, issueID, resolutionID, close):
		# This one probably might use some improvements
		pattern = re.compile(r'</issue>')
		pResolution = re.compile(r'<resolution>(\d+)</resolution>')
		closePattern = re.compile(r'<closed>(\w+)</closed>')

		if pResolution.match(issue):
			issue = pResolution.sub('<resolution>{id}</resolution>'.format(id = resolutionID), issue)
		else:
			issue = pattern.sub('<resolution>{id}</resolution></issue>'.format(id = resolutionID), issue)

		if close:
			issue = closePattern.sub('<closed>true</closed>', issue)

		# Updating Issue
		resp, content = h.request("{uri}/api/issues/{id}".format(uri = FIXX_URI, id = issueID), "PUT", body = issue, headers = headersXML)

	# Posting a commentary
	def postComment(gitLog, comment):
		resp, content = h.request("{uri}/api/issues/{id}/comments".format(uri = FIXX_URI, id = gitLog['issueID']), "POST", body = comment, headers = headersXML )

	# We get resolutions in aa dictionary format
	resolutions = getResolutions(PROJECT_ID)

	# If there's a resolution specified, we update the issue and psot comment
	#   if not, we just post a comment (useful if you just want to make some updates)
	if gitLog['issueResolution'] in resolutions:
		resolveIssue(issue, gitLog['issueID'], resolutions[gitLog['issueResolution']], gitLog['close'])
		postComment(gitLog, comment)
	else:
		postComment(gitLog, comment)