├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── github_hook ├── __init__.py ├── admin.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── urls.py └── views.py ├── runtests.sh ├── setup.py └── tests ├── __init__.py ├── bitbucket.json ├── github.json ├── github2.json ├── settings.py ├── test.sh └── test_github.py /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | 3 | *~ 4 | *.pyc 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | env: 8 | global: 9 | - DRF="djangorestframework==3.6.2" 10 | matrix: 11 | - DJANGO="django==1.8.17" 12 | - DJANGO="django==1.9.12" 13 | - DJANGO="django==1.10.6" 14 | - DJANGO="django==1.10.6" LINT=1 15 | install: 16 | - pip install $DJANGO $DRF 17 | - pip install flake8 18 | script: 19 | - ./runtests.sh 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2017, S. Andrew Sheppard, http://wq.io/ 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | github\_hook 2 | ============ 3 | 4 | Simple continuous integration for Django developers, taking the form of 5 | a Django app for managing GitHub (or BitBucket) post receive hooks. 6 | 7 | .. image:: https://img.shields.io/travis/sheppard/django-github-hook.svg 8 | :target: https://travis-ci.org/sheppard/django-github-hook 9 | .. image:: https://img.shields.io/pypi/v/django-github-hook.svg 10 | :target: https://pypi.python.org/pypi/django-github-hook 11 | 12 | Tested on Python 2.7, 3.4 and 3.5, with Django 1.8 and 1.9. 13 | 14 | Usage 15 | ----- 16 | 17 | - ``pip install django-github-hook`` 18 | - Add ``github_hook`` to ``INSTALLED_APPS`` in your settings.py 19 | - ``./manage.py migrate`` (or ``./manage.py syncdb``) 20 | - Add e.g. ``url(r'^hook/', include('github_hook.urls'))`` to your 21 | urls.py ``urlpatterns`` 22 | - Log into the Django admin console 23 | - Configure your hook with the folowing fields: 24 | 25 | - *Name*: Hook identifier 26 | - *User*: Repo username 27 | - *Repo*: Repo name 28 | - *Path*: Absolute path to script to execute 29 | 30 | - Go to your repo's "Service Hooks" settings on GitHub (or BitBucket) and add a 31 | WebHook/POST URL: 32 | 33 | - http[s]://[yourwebsite]/hook 34 | - The repo information will be read from the JSON payload 35 | 36 | - Alternatively, you can specify a specific hook by name: 37 | 38 | - http[s]://[yourwebsite]/hook/name 39 | 40 | 41 | Examples 42 | -------- 43 | 44 | The following snippet show how to connect the webhook to a method using django's signal mechanism. 45 | Note that path must be set to "send-signal" in the hook object instead of an absolute path to a script. 46 | 47 | .. code-block:: python 48 | 49 | from github_hook.models import hook_signal 50 | 51 | def processWebhook(sender, **kwargs): 52 | for key, value in kwargs.iteritems(): 53 | print key, value 54 | 55 | hook_signal.connect(processWebhook) 56 | 57 | -------------------------------------------------------------------------------- /github_hook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sheppard/django-github-hook/6b1b2328beb4e499d47e703d91af2ac45de13bd5/github_hook/__init__.py -------------------------------------------------------------------------------- /github_hook/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Hook 3 | admin.site.register(Hook) 4 | -------------------------------------------------------------------------------- /github_hook/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.1 on 2016-01-06 01:12 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Hook', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=255)), 21 | ('user', models.CharField(max_length=255)), 22 | ('repo', models.CharField(max_length=255)), 23 | ('path', models.CharField(max_length=255)), 24 | ], 25 | options={ 26 | 'db_table': 'github_hook', 27 | }, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /github_hook/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sheppard/django-github-hook/6b1b2328beb4e499d47e703d91af2ac45de13bd5/github_hook/migrations/__init__.py -------------------------------------------------------------------------------- /github_hook/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.encoding import python_2_unicode_compatible 3 | from django import dispatch 4 | import subprocess 5 | 6 | 7 | @python_2_unicode_compatible 8 | class Hook(models.Model): 9 | name = models.CharField(max_length=255) 10 | user = models.CharField(max_length=255) 11 | repo = models.CharField(max_length=255) 12 | path = models.CharField(max_length=255) 13 | 14 | def execute(self): 15 | subprocess.call([self.path]) 16 | 17 | def __str__(self): 18 | return "%s (%s/%s)" % (self.name, self.user, self.repo) 19 | 20 | class Meta: 21 | db_table = 'github_hook' 22 | 23 | 24 | class HookSignal(dispatch.Signal): 25 | pass 26 | 27 | 28 | hook_signal = HookSignal() 29 | -------------------------------------------------------------------------------- /github_hook/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views import HookView 4 | 5 | app_name = 'github_hook' 6 | urlpatterns = [ 7 | url(r'^(?P[\w-]+)$', 8 | HookView.as_view()), 9 | url(r'^$', 10 | HookView.as_view()), 11 | ] 12 | -------------------------------------------------------------------------------- /github_hook/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import json 4 | 5 | from rest_framework.response import Response 6 | from rest_framework.generics import GenericAPIView 7 | from rest_framework.renderers import JSONRenderer 8 | from rest_framework.exceptions import ParseError 9 | 10 | from django.views.decorators.csrf import csrf_exempt 11 | from .models import Hook, hook_signal 12 | 13 | logger = logging.getLogger(__name__) 14 | logger.setLevel(logging.DEBUG) 15 | logging.basicConfig() 16 | 17 | 18 | class HookView(GenericAPIView): 19 | renderer_classes = [JSONRenderer] 20 | 21 | @csrf_exempt 22 | def post(self, request, *args, **kwargs): 23 | # Explicit hook name 24 | name = kwargs.get('name', None) 25 | 26 | # Git repo information from post-receive payload 27 | if request.content_type == "application/json": 28 | payload = request.data 29 | else: 30 | # Probably application/x-www-form-urlencoded 31 | payload = json.loads(request.data.get("payload", "{}")) 32 | 33 | info = payload.get('repository', {}) 34 | repo = info.get('name', None) 35 | 36 | # GitHub: repository['owner'] = {'name': name, 'email': email} 37 | # BitBucket: repository['owner'] = name 38 | user = info.get('owner', {}) 39 | if isinstance(user, dict): 40 | user = user.get('name', None) 41 | 42 | if not name and not repo and not user: 43 | raise ParseError( 44 | "No JSON data or URL argument : cannot identify hook" 45 | ) 46 | 47 | # Find and execute registered hook for the given repo, fail silently 48 | # if none exist 49 | try: 50 | hook = None 51 | if name: 52 | hook = Hook.objects.get(name=name) 53 | elif repo and user: 54 | hook = Hook.objects.get(user=user, repo=repo) 55 | if hook: 56 | if hook.path != "send-signal": 57 | hook.execute() 58 | else: 59 | hook_signal.send( 60 | HookView, info=info, repo=repo, 61 | user=user, request=request 62 | ) 63 | 64 | except Hook.DoesNotExist: 65 | # If there is not a script defined, then send a HookSignal 66 | hook_signal.send(HookView, request=request, payload=payload) 67 | logger.debug('Signal {} sent'.format(hook_signal)) 68 | return Response({}) 69 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | if [ "$LINT" ]; then 3 | flake8 github_hook tests --exclude migrations 4 | else 5 | python setup.py test 6 | fi 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | :copyright: Copyright 2013-2017 by S. Andrew Sheppard 5 | :contact: andrew@wq.io 6 | """ 7 | 8 | 9 | from setuptools import setup 10 | from os.path import join, dirname 11 | 12 | LONG_DESCRIPTION = """ 13 | Webhooks for GitHub post-receive hooks and other POST requests. 14 | """ 15 | 16 | 17 | def long_description(): 18 | """Return long description from README.rst if it's present 19 | because it doesn't get installed.""" 20 | try: 21 | return open(join(dirname(__file__), 'README.rst')).read() 22 | except IOError: 23 | return LONG_DESCRIPTION 24 | 25 | 26 | setup( 27 | name='django-github-hook', 28 | version='0.2.1', 29 | description='Django-powered GitHub (& Bitbucket) web hooks', 30 | long_description=long_description(), 31 | author='S. Andrew Sheppard & Contributors', 32 | author_email='andrew@wq.io', 33 | packages=['github_hook'], 34 | install_requires=['Django', 'djangorestframework'], 35 | classifiers=[ 36 | 'Intended Audience :: Developers', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 2', 39 | 'Programming Language :: Python :: 2.7', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3.4', 42 | 'Programming Language :: Python :: 3.5', 43 | 'Topic :: Software Development :: Libraries :: Python Modules', 44 | 'Framework :: Django', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Development Status :: 4 - Beta', 47 | ], 48 | platforms=['any'], 49 | test_suite='tests' 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.test.utils import setup_test_environment 3 | import django 4 | from django.core.management import call_command 5 | 6 | 7 | os.environ['DJANGO_SETTINGS_MODULE'] = "tests.settings" 8 | setup_test_environment() 9 | django.setup() 10 | call_command('makemigrations') 11 | call_command('migrate') 12 | -------------------------------------------------------------------------------- /tests/bitbucket.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": { 3 | "name":"test", 4 | "owner": "user" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": { 3 | "name":"test", 4 | "owner": { 5 | "name": "user", 6 | "email": "user@example.com" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/github2.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": { 3 | "name":"test2", 4 | "owner": { 5 | "name": "user", 6 | "email": "user@example.com" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = '1234' 2 | INSTALLED_APPS = ( 3 | 'django.contrib.auth', 4 | 'django.contrib.contenttypes', 5 | 'github_hook', 6 | ) 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': ':memory:', 11 | } 12 | } 13 | ROOT_URLCONF = 'github_hook.urls' 14 | -------------------------------------------------------------------------------- /tests/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Hello\c" >> tests/test.log 3 | -------------------------------------------------------------------------------- /tests/test_github.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from github_hook.models import Hook, hook_signal 3 | from rest_framework import status 4 | import json 5 | 6 | 7 | class GithubHookTestCase(TestCase): 8 | def setUp(self): 9 | Hook.objects.create( 10 | name='test', 11 | user='user', 12 | repo='test', 13 | path='tests/test.sh', 14 | ) 15 | self.github_payload = json.load(open('tests/github.json')) 16 | self.github_payload2 = json.load(open('tests/github2.json')) 17 | self.bitbucket_payload = json.load(open('tests/bitbucket.json')) 18 | 19 | def test_plain_post(self): 20 | """ 21 | POST without payload or name is invalid 22 | """ 23 | response = self.client.post('/') 24 | self.assertFalse(status.is_success(response.status_code)) 25 | 26 | def test_named_post(self): 27 | """ 28 | POST with valid name (url slug) should work. 29 | """ 30 | self.reset_log() 31 | response = self.client.post('/test') 32 | self.assertTrue(status.is_success(response.status_code)) 33 | self.check_log() 34 | 35 | def test_github_post(self): 36 | """ 37 | POST with valid Github-style form payload should work. 38 | """ 39 | self.reset_log() 40 | data = {"payload": json.dumps(self.github_payload)} 41 | response = self.client.post('/', data) 42 | self.assertTrue(status.is_success(response.status_code)) 43 | self.check_log() 44 | 45 | def test_github_json(self): 46 | """ 47 | POST with valid Github-style JSON payload should work. 48 | """ 49 | self.reset_log() 50 | data = json.dumps(self.github_payload) 51 | response = self.client.post('/', data, content_type="application/json") 52 | self.assertTrue(status.is_success(response.status_code)) 53 | self.check_log() 54 | 55 | def test_github_signal(self): 56 | """ 57 | POST with valid payload but unknown user/repo should trigger signal. 58 | """ 59 | def test_fn(sender, **kwargs): 60 | self.write_log() 61 | hook_signal.connect(test_fn) 62 | 63 | self.reset_log() 64 | data = json.dumps(self.github_payload2) 65 | response = self.client.post('/', data, content_type="application/json") 66 | self.assertTrue(status.is_success(response.status_code)) 67 | self.check_log() 68 | 69 | def test_bitbucket_post(self): 70 | """ 71 | POST with valid Bitbucket-style form payload should work. 72 | """ 73 | self.reset_log() 74 | data = {"payload": json.dumps(self.bitbucket_payload)} 75 | response = self.client.post('/', data) 76 | self.assertTrue(status.is_success(response.status_code)) 77 | self.check_log() 78 | 79 | def test_get(self): 80 | """ 81 | GET should not work. 82 | """ 83 | response = self.client.get('/') 84 | self.assertFalse(status.is_success(response.status_code)) 85 | response = self.client.get('/test') 86 | self.assertFalse(status.is_success(response.status_code)) 87 | 88 | def reset_log(self): 89 | self.write_log("") 90 | 91 | def write_log(self, value="Hello"): 92 | f = open('tests/test.log', 'w') 93 | f.write(value) 94 | f.close() 95 | 96 | def check_log(self, value="Hello"): 97 | f = open('tests/test.log') 98 | data = f.read() 99 | f.close() 100 | self.assertEqual(data, value) 101 | --------------------------------------------------------------------------------