☑ Hooked on Github

Github’s web hooks make it surprisingly easy to write commit triggers.

github usb

I’ve been using Github for awhile now and I’ve found it to be a very handy little service. I recently discovered just how easy it is to add commit triggers to it, however.

If you look under Settings for a repository and select the Service Hooks option, you’ll see a whole slew of pre-written hooks for integrating your repository into a variety of third party services. These range from bug trackers to automatically posting messages to IRC chat rooms. If you happen to be using one of these services, things are pretty easy.

If you want to integrate with your own service, however, things are almost as easy. In this post, I’ll demonstrate how easy by presenting a simple WSGI application which can keep one or more local repositories on a server synchronised by triggering a git pull command whenever a commit is made to the origin.

Firstly, here’s the script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import git
import json
import urlparse


class RequestError(Exception):
    pass


# Update this to include all the Github repositories you wish to watch.
REPO_MAP = {
    "repo-name": "/home/user/src/git-repo-path"
}


def handle_commit(payload):
    """Called for each commit any any watched repository."""

    try:
        # Only pay attention to commits on master.
        if payload["ref"] != 'refs/heads/master':
            return False
        # Obtain local path of repo, if found.
        repo_root = REPO_MAP.get(payload["repository"]["name"], None)
        if repo_root is None:
            return False

    except KeyError:
        raise RequestError("422 Unprocessable Entity")

    # This block performs a "git pull --ff-only" on the repository.
    repo = git.Repo(repo_root)
    repo.remotes.origin.pull(ff_only=True)
    return True


def application(environ, start_response):
    """WSGI application entry point."""

    try:
        # The Github webhook interface always sends us POSTs.
        if environ["REQUEST_METHOD"] != 'POST':
            raise RequestError("405 Method Not Allowed")

        # Extract and parse the body of the POST.
        post_data = urlparse.parse_qs(environ['wsgi.input'].read())

        # Github's webhook interface sends a single "payload" parameter
        # whose value is a JSON-encoded object.
        try:
            payload = json.loads(post_data["payload"][0])
        except (IndexError, KeyError, ValueError):
            raise RequestError("422 Unprocessable Entity")

        # If the request looks valid, pass to handle_commit() which
        # returns True if the commit was handled, False otherwise.
        if handle_commit(payload):
            start_response("200 OK", [("Content-Type", "text/plain")])
            return ["ok"]
        else:
            start_response("403 Forbidden", [("Content-Type", "text/plain")])
            return ["ignored ref"]

    except RequestError as e:
        start_response(str(e), [("Content-Type", "text/plain")])
        return ["request error"]

    except Exception as e:
        start_response("500 Internal Server Error",
                       [("Content-Type", "text/plain")])
        return ["unhandled exception"]

Aside from the Python standard library it also uses the GitPython library for accessing the Git repositories. Please also note that this application is a bare-bones example — it lacks important features such as logging and more graceful error-handling, and it could do with being rather more configurable, but hopefully it’s a reasonable starting point.

To use this application, update the REPO_MAP dictionary to contain all the repositories you wish to watch for updates. The key to the dictionary should be the name of the repository as specified on Github, the value should be the full, absolute path to a checkout of that repository where the Github repository is added as the origin remote (i.e. as if created with git clone). The repository should remaind checked out on the master branch.

Once you have this application up and running you’ll need to note its URL. You then need to go to the Github Service Hooks section and click on the WebHook URLs option at the top of the list. In the text box that appears on the right enter the URL of your WSGI application and hit Update settings.

Now whenever you perform a commit to the master branch of your Github repository, the web hook will trigger a git pull to keep the local repository up to date.

Primarily I’m hoping this serves as an example for other, more useful web hooks, but potentially something like this could serve as a way to keep a production website up to date. For example, if refs/heads/master in the script above is changed to refs/heads/staging and you kept the local repository always checked out on that branch, you could use it as a way to push updates to a staging server just by performing an appropriate commit on to that branch in the master repository.

Also note that the webhook interface contains a lot of rich detail which could be used to do things like update external bug trackers, update auto-generated documentation or a ton of other handy ideas. Github have a decent enough reference for the content of the POSTs your hook will receive and my sample above only scratches the surface.

16 May 2013 at 11:52AM by Andy Pearce in Software  | Photo by Brina Blum on Unsplash  | Tags: web  git python