Posting to Slack via Git Hooks

At my day job our codebase is kept in a handful of self-hosted Git repositories. We have a tool that runs nightly, emailing out a digest of all of the previous day’s commits.

It’s kinda cool but I have the tendency to ignore it as a wall of text. I prefer more granular messaging and since we’re also using Slack, I saw an opportunity to do something with a post-receive hook to get a message as changes came in.

Posting from Git to Slack isn’t revolutionary. There are a tons of solutions for this out there. In fact, my original attempt just used a modified version of Chris Eldredge’s shell script, which I grabbed off of GitHub.  However, my Bash-foo is weak and we’re a PHP shop so I decided to write a solution based in PHP (though heavily based on Eldredge as I had that code in front of me).

To fire off the PHP script, the post-receive hook looks like this:

#!/bin/bash
while read oldrev newrev refname
	php git-slack-post.php --oldrev="$oldrev" --newrev="$newrev" --refname="$refname"
done

That’s simplified a bit as the actual hooks use an absolute path to the script but you see that the script accepts the oldrev, newrev, and refname arguments.

As for the PHP script itself, it looks a bit like this (I’ve sanitized some things to remove references to our internal services).

#!/usr/bin/php -f
<?php
// expects to be called as follows:
// php git-slack-post.php --oldrev="1234567890abcdef" --newrev="0987654321fedcba" --refname="refs/foo/bar"

const WEBHOOK_URL = 'https://hooks.slack.com/services/ABC123FDG/456HIJ789/tWeNtYfOuRcHaRaCtErS0024';
const ZEROREV = '0000000000000000000000000000000000000000';

// pull the arguments from the command line
$newrev = $oldrev = $refname = '';
extract(getopt('', array('oldrev:', 'newrev:', 'refname:')));

// determine change type based on what revisions are available
if ($oldrev === ZEROREV) {
	$change_type = 'create';
} elseif ($newrev === ZEROREV) {
	$change_type = 'delete';
} else {
	$change_type = 'update';
}

$newrev_type = trim(`git cat-file -t {$newrev}`);
$oldrev_type = trim(`git cat-file -t {$oldrev}`);

$rev = $rev_type = '';
if (in_array($change_type, array('create', 'update'))) {
	$rev = $oldrev;
	$rev_type = $oldrev_type;
} elseif ($change_type === 'delete') {
	$rev = $newrev;
	$rev_type = $newrev_type;
}

if ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'tag')) {
	$refname_type = 'annotated tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/heads/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'branch';
	$short_refname = str_replace('refs/heads/', '', $refname);
} elseif ((strpos($refname, 'refs/remotes/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tracking branch';
	$short_refname = str_replace('refs/remotes/', '', $refname);

	echo '*** Push-update of tracking branch, ' . $refname;
	echo '***  - no notification generated.';

	exit;
} else {
	// this shouldn't be possible
	echo '*** Unknown type of update to ' . $refname . ' (' . $rev_type . ')';
	echo '***  - no notification generated.';

	exit;
}

// get the repo name
// only get the final directory name, cut ".git" off the end
$repo = substr(basename(realpath(getcwd())), 0, -4);

// get the user
$process_user = posix_getpwuid(posix_geteuid());
$user = $process_user['name'];

$header = '[' . $repo . '/' . $short_refname . '] ';
switch ($change_type) {
	case 'create':
		$header .= 'New ' . $refname_type . ' has been created';
		break;
	case 'delete':
		$header .= ucwords($refname_type) . ' has been deleted';
		break;
	case 'update':
		$commits = intval(trim(`git log --pretty=oneline {$oldrev}..{$newrev}|wc -l`));
		$header .= $commits . ' new commit' . (($commits > 1) ? 's' : '') . ' pushed by ' . $user;
		break;
	default:
		// this shouldn't be possible
		echo '*** Unknown type of update to ' . $refname . '(' . $rev_type . ')';
		echo '***  - notifications will probably screw up.';
		break;
}

$start = ($change_type === 'update') ? $oldrev : 'HEAD';
$end = $newrev;

// get git data
$data = `git log --pretty=format:"%an&&&&&%h&&&&&%s&&&&&%b@@@@@" {$start}..{$end}`;

$attachments = array();
foreach (explode('@@@@@', $data) AS $item) {
	if (!trim($item)) {
		continue;
	}
	list($author, $hash, $comment, $body) = explode('&&&&&', $item);

	$text = trim('`' . $hash . '` ' . $comment . ' - *' . trim($author) . '*' . "\n\n" . $body);
	$fallback = trim($hash . ' - ' . $comment . ' - ' . trim($author) . "\n\n" . $body);

	// link jira tasks
	$text = preg_replace('#ABC-(\d+)#is', '<https://jira.example.com/browse/ABC-$1|ABC-$1>', $text);

	$text = preg_replace('#\[ABC\][ ]?\((\d+)\)#is', '<https://jira.example.com/browse/ABC-$1|$0>', $text);

	// link support bugs/tickets
	if (preg_match('#(TICKET|BUG|SUPPORT)[\-| ](\d+)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.php?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

	if (preg_match('#\[HOTFIX\][ ]?\((\d+)\)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.mh?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

	$attachments[] = array(
		'text'      => $text,
		'fallback'  => $fallback,
		'color'     => '#1881d0',
		'mrkdwn_in' => array('text'),
	);
}

// build message
$msg = array('text' => $header, 'attachments' => $attachments);

// send message
$c = curl_init();
curl_setopt($c, CURLOPT_URL, WEBHOOK_URL);
curl_setopt($c, CURLOPT_POST, 1);
curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($c, CURLOPT_HEADER, 0);
curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
curl_exec($c);
curl_close($c);

We get details about what’s being pushed and build a message out of all of that. Simple enough. So lets break that down a little bit.

const WEBHOOK_URL = 'https://hooks.slack.com/services/ABC123FDG/456HIJ789/tWeNtYfOuRcHaRaCtErS0024';
const ZEROREV = '0000000000000000000000000000000000000000';

// pull the arguments from the command line
$newrev = $oldrev = $refname = '';
extract(getopt('', array('oldrev:', 'newrev:', 'refname:')));

We start by defining our Slack webhook URL (which can be set up at https://my.slack.com/services/new/incoming-webhook) and what an “empty” revision number looks like. Then we pull in the oldrev, newrev, and refname that should have been passed into the script as arguments.

// determine change type based on what revisions are available
if ($oldrev === ZEROREV) {
	$change_type = 'create';
} elseif ($newrev === ZEROREV) {
	$change_type = 'delete';
} else {
	$change_type = 'update';
}

$newrev_type = trim(`git cat-file -t {$newrev}`);
$oldrev_type = trim(`git cat-file -t {$oldrev}`);

If the old revision is empty, it means we’re creating something. If the new revision is empty, it means we’re deleting something. Otherwise it’s an update of something that already existed and continues to do so.

Whatever the change type, we get more information about the old and new revisions by using the backtick operator to run the get cat-file -t command for each revision number.

$rev = $rev_type = '';
if (in_array($change_type, array('create', 'update'))) {
	$rev = $oldrev;
	$rev_type = $oldrev_type;
} elseif ($change_type === 'delete') {
	$rev = $newrev;
	$rev_type = $newrev_type;
}

If the change type is a create or an update, we’ll use the old revision data to reference things going forward. If it’s a delete we’ll use the new revision data.

if ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'tag')) {
	$refname_type = 'annotated tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/heads/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'branch';
	$short_refname = str_replace('refs/heads/', '', $refname);
} elseif ((strpos($refname, 'refs/remotes/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tracking branch';
	$short_refname = str_replace('refs/remotes/', '', $refname);

	echo '*** Push-update of tracking branch, ' . $refname;
	echo '***  - no notification generated.';

	exit;
} else {
	// this shouldn't be possible
	echo '*** Unknown type of update to ' . $refname . ' (' . $rev_type . ')';
	echo '***  - no notification generated.';

	exit;
}

This is just a bunch of logic that looks at the refname and the revision type and determines exactly what you’ve pushed. If we can’t figure out what it is, we exit with an error.

// get the repo name
// only get the final directory name, cut ".git" off the end
$repo = substr(basename(realpath(getcwd())), 0, -4);

// get the user
$process_user = posix_getpwuid(posix_geteuid());
$user = $process_user['name'];

We determine the repo name based on the path the hook is running from and we get the user it’s running as so we know who did the push we’re about to notify people of.

$header = '[' . $repo . '/' . $short_refname . '] ';
switch ($change_type) {
	case 'create':
		$header .= 'New ' . $refname_type . ' has been created';
		break;
	case 'delete':
		$header .= ucwords($refname_type) . ' has been deleted';
		break;
	case 'update':
		$commits = intval(trim(`git log --pretty=oneline {$oldrev}..{$newrev}|wc -l`));
		$header .= $commits . ' new commit' . (($commits > 1) ? 's' : '') . ' pushed by ' . $user;
		break;
	default:
		// this shouldn't be possible
		echo '*** Unknown type of update to ' . $refname . '(' . $rev_type . ')';
		echo '***  - notifications will probably screw up.';
		break;
}

Now we start building the message that will be posted to Slack. The message begins in the form of “[reponame/branchname]. If it’s a create or delete, we then note what was created or deleted. If it’s a commit, we note the number of commits and who they were pushed by.

$start = ($change_type === 'update') ? $oldrev : 'HEAD';
$end = $newrev;

// get git data
$data = `git log --pretty=format:"%an&&&&&%h&&&&&%s&&&&&%b@@@@@" {$start}..{$end}`;

$attachments = array();
foreach (explode('@@@@@', $data) AS $item) {
	if (!trim($item)) {
		continue;
	}
	list($author, $hash, $comment, $body) = explode('&&&&&', $item);

We’re going to start building a series of messages (what Slack calls “attachments”) detailing items from the Git log pertinent to this push. We use the git log command and define our format. We get the author name with %an, the hash with %h, the commit message with %s and the commit body with %b. Those are all separated by five ampersands, with each item separated by five at signs. We use those goofy separators so we can split on them later, as it’s unlikely anyone enters those in text.

	$text = trim('`' . $hash . '` ' . $comment . ' - *' . trim($author) . '*' . "\n\n" . $body);
	$fallback = trim($hash . ' - ' . $comment . ' - ' . trim($author) . "\n\n" . $body);

Here we actually build our message. The text property of a Slack attachment can be markdown, so we pretty it up a little bit. The fallback property is plaintext so it doesn’t get that formatting. The result is the hash, then the commit message, then the author. If there is a longer commit message, it gets added after that.

	// link jira tasks
	$text = preg_replace('#ABC-(\d+)#is', '<https://jira.example.com/browse/ABC-$1|ABC-$1>', $text);

	$text = preg_replace('#\[ABC\][ ]?\((\d+)\)#is', '<https://jira.example.com/browse/ABC-$1|$0>', $text);

	// link support bugs/tickets
	if (preg_match('#(TICKET|BUG|SUPPORT)[\-| ](\d+)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.php?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

	if (preg_match('#\[HOTFIX\][ ]?\((\d+)\)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.mh?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

We’re not done with that text yet, though. We have a loosely-followed naming convention for our commits and we can use that to link back to other systems that might have more information about the commit. Anything that was a Jira task should start with the task number in the format “[ABC-1234]” but sometimes it’s “(ABC-1234)” or “[ABC] (1234)” or “[ABC](1234)” so we account for all of those. Similarly, references to our ticket system sometimes use “TICKET” or “BUG” or “SUPPORT” and sometimes have a space or a dash and sometimes use “HOTFIX” and… You get the idea. There are probably better regular expressions to use here but these work. So we find references to our Jira cards and link back to them, then find references to our support system and link back to it, where there’s a script that will do some additional parsing to figure out where to go.

	$attachments[] = array(
		'text'      => $text,
		'fallback'  => $fallback,
		'color'     => '#1881d0',
		'mrkdwn_in' => array('text'),
	);

With all of that done, we build an array for this attachment, defining our text, our fallback text, a color to display alongside the attachment, and confirming that there is markdown in our text field.

}

// build message
$msg = array('text' => $header, 'attachments' => $attachments);

// send message
$c = curl_init();
curl_setopt($c, CURLOPT_URL, WEBHOOK_URL);
curl_setopt($c, CURLOPT_POST, 1);
curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($c, CURLOPT_HEADER, 0);
curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
curl_exec($c);
curl_close($c);

Once we’re done looping through our data from the git log and building our attachments, we put together the message and send it off via Curl. The message is just an array, which then gets JSON-encoded and posted to our webhook URL.

It’s fire-and-forget, so we don’t make any note if the webhook doesn’t respond or anything like that.

For a short time we had an additional message attachment that included diff data but we decided we didn’t want our code getting posted to Slack so we removed it.

As I said, there are tons of solutions for this out there, this is just one more.

Leave a Reply

Your email address will not be published. Required fields are marked *