├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── dev.yml ├── docker-compose.yml ├── dockerfiles └── nginx │ ├── Dockerfile │ └── nginx.conf ├── hook ├── hook.py └── hook_test.py ├── repos └── example.py ├── requirements.txt └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | ### OSX ### 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | ### SublimeText ### 7 | # cache files for sublime text 8 | *.tmlanguage.cache 9 | *.tmPreferences.cache 10 | *.stTheme.cache 11 | 12 | # workspace files are user-specific 13 | *.sublime-workspace 14 | 15 | # project files should be checked into the repository, unless a significant 16 | # proportion of contributors will probably not be using SublimeText 17 | # *.sublime-project 18 | 19 | # sftp configuration file 20 | sftp-config.json 21 | 22 | # Basics 23 | *.py[cod] 24 | *.pyc 25 | __pycache__ 26 | 27 | # Logs 28 | *.log 29 | pip-log.txt 30 | 31 | # Unit test / coverage reports 32 | .coverage 33 | .tox 34 | nosetests.xml 35 | htmlcov 36 | 37 | # Translations 38 | *.mo 39 | *.pot 40 | 41 | # Pycharm 42 | .idea 43 | 44 | # Vim 45 | 46 | *~ 47 | *.swp 48 | *.swo 49 | 50 | # npm 51 | node_modules/ 52 | 53 | # Compass 54 | .sass-cache 55 | 56 | # tls 57 | key 58 | dhparam.pem 59 | key.pem 60 | 61 | # htpasswd 62 | .htpasswd -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | after_success: 2 | - codecov 3 | before_install: 4 | - pip install codecov tox 5 | 6 | language: python 7 | 8 | matrix: 9 | include: 10 | - env: TOXENV=py27 11 | python: 2.7 12 | - env: TOXENV=py33 13 | python: 3.3 14 | - env: TOXENV=py34 15 | python: 3.4 16 | - env: TOXENV=py35 17 | python: 3.5 18 | 19 | script: tox -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5-slim 2 | ENV PYTHONUNBUFFERED 1 3 | 4 | ADD requirements.txt /app/requirements.txt 5 | 6 | RUN pip install -r /app/requirements.txt 7 | 8 | RUN groupadd -r hook && useradd -r -g hook hook 9 | 10 | ADD hook /app/hook 11 | ADD repos /app/repos 12 | 13 | WORKDIR /app/hook -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jannis Gebauer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Updates](https://pyup.io/repos/github/jayfk/octohook/shield.svg)](https://pyup.io/repos/github/pyupio/octohook/) [![Build Status](https://travis-ci.org/jayfk/octohook.svg?branch=master)](https://travis-ci.org/pyupio/octohook) [![codecov.io](https://codecov.io/github/jayfk/octohook/coverage.svg?branch=master)](https://codecov.io/github/pyupio/octohook?branch=master) 2 | 3 | ## About 4 | 5 | Octohook is a server that listens for incoming webhooks, validates them and routes them to your code. The idea was to make it easier to write and deploy code that runs when something happens on your repo. Be it a new issue, a reverted commit or someone starring it. 6 | 7 | ### How it works 8 | 9 | Octohook uses flask to serve incoming requests and to establish routes. When the server starts, it checks for files in the `repos/` folder, imports them and establishes a route to them by using the filename. Once a POST request hits the URL, the view function calls the appropiate function for the event in that module. 10 | 11 | 12 | For example, if you create a file `repos/myrepo.py`, the server will listen for incoming webhooks at `/myrepo/`. When a webhook for the event `fork` hits `/myrepo/`, the `fork` function in `repo/myrepo.py` is called. 13 | 14 | Or, if you have a repo called `foo` and you want to run some code on `pull_request` events you create a file `repos/foo.py` and implement a `pull_request(data)` in it. Your server now listens on `/foo/` and waits for hooks to come by. 15 | 16 | ## Get started (quick) 17 | 18 | 1. Clone the repo 19 | 20 | git clone https://github.com/pyupio/octohook.git 21 | 22 | 2. Add your code in `repos/whateveryouwant.py`. 23 | 24 | 3. Start the server 25 | 26 | **With vanilla python** 27 | 28 | mkvirtualenv octohook 29 | pip install -r requirements.txt 30 | export DEBUG=True 31 | python hook/hook.py 32 | 33 | **Or with docker** 34 | 35 | docker-compose -f dev.yml up 36 | 37 | 4. Use ngrok during development to forward request to your local machine. The server listens on port `5000`. 38 | 39 | 40 | 41 | ## Get started 42 | 43 | First, clone the repo by running 44 | 45 | git clone https://github.com/pyupio/octohook.git 46 | 47 | Now, take a look at the `repos/` folder. This is where incoming webhooks will be routed to. There's a file called `example.py` in that folder. Every function you see in there maps an event sent by Github. 48 | 49 | Move the file and name it eg. `myrepo.py` 50 | 51 | mv repos/example.py repos/myrepo.py 52 | 53 | As an easy example to begin with we are going to listen to the `watch` event and print a message to the terminal once someone stars our repo. 54 | 55 | Open `myrepo.py` and delete all functions except for the `watch` function at the end of the file. We are going to add a simple print statement to the function that tells us who has starred the repo and how much stars the repo has in total. 56 | 57 | def watch(data): 58 | """Any time a User stars a Repository.""" 59 | print("{user} just starred {repo_name}. The repo now has a total of {stars} stars".format( 60 | user=data["sender"]["login"], 61 | repo_name=data["repository"]["name"], 62 | stars=data["repository"]["watchers_count"] 63 | )) 64 | 65 | Now we need to run the server. Octohook comes with built in docker support using docker-compose, and of course vanilla python. 66 | 67 | **Vanilla python** 68 | 69 | mkvirtualenv octohook 70 | pip install -r requirements.txt 71 | export DEBUG=True 72 | python hook/hook.py 73 | 74 | **Or with docker** 75 | 76 | docker-compose -f dev.yml up 77 | 78 | You should see a warning telling you that you are running in `DEBUG` mode. That's because the `DEBUG` environment variable is set. There's no signature verification on `DEBUG`, take a look at [Security](###security) for more on that. 79 | 80 | Since we are probably running on a local machine that isn't visible to Githubs servers, install [ngrok](https://ngrok.com/) that helps us to tunnel incoming webhooks to our dev machine. 81 | 82 | Open a new terminal and run: 83 | 84 | ngrok 127.0.0.1:5000 85 | 86 | *If you are using docker on OSX/Windows, replace `127.0.0.1` with the IP of your docker deamon. Run `docker-machine ip default` to get it.* 87 | 88 | Create a new repo or use an existing one and go to click on `Settings` > `Webhooks & services` > `Add webhook`. 89 | 90 | Take a look at the terminal where you started `ngrok`, copy the forwarding URL into the Payload URL field and add `/myrepo/` to it, so that it looks like `http://783yk8fae.ngrok.com/myrepo/`. Leave the secret empty and make sure to click on *Send me everything*. 91 | 92 | Now get back to your github repo. To trigger the `watch` event, click on the star button. (You might need to click twice if you already starred your repo). 93 | 94 | The octohook server should now print 95 | 96 | jayfk just starred test-repo. The repo now has a total of 1 stars 97 | 98 | And ngrok should tell you that it forwarded the request sucessfully 99 | 100 | POST /myrepo/ 200 OK 101 | 102 | 103 | Not what you are seeing? Go to the `Webhooks & services` page again. And click on the webhook you just created. Check the `Recent Deliveries` Pane and check the `Response` tab to see the error. 104 | 105 | ## Security 106 | 107 | Github signs the payload if you set a secret token during the creation of the webhook on the web interface. That's generally a very good thing, because otherwise everyone could POST funny payloads to your server. 108 | 109 | Octohook verifies the signature by default and won't continue to process the request if it doesn't match. In fact, octohook will even refuse to start when the secret for a repo is not set. 110 | 111 | To tell octohook the secret for your repo, you need set the environment variable `REPONAME_SECRET`. For example, if you have a file `repos/foo.py` you'll need to set the environment variable `FOO_SECRET`. If you have file `repos/bar.py`, you'll need to set `BAR_SECRET`. 112 | 113 | The only exception where octohook won't verify incoming payloads, is when you set the `DEBUG` environment variable. That makes it easier during development, because you don't have to sign your payloads if you are developing. 114 | 115 | ## Deploy 116 | 117 | When you are done with testing and you want to run that thing on a real server there's good news: Octohook comes with a docker-compose configuration that makes it easy to deploy your code to a live server. 118 | 119 | The configuration uses nginx with a self signed certificate (that is auto generated during the build) and a gunicorn server that runs the code. 120 | 121 | You can use whatever provider you prefer. For simplicity, we are going to use Digital Ocean. Make sure to check docker-machine`s [provider list](https://github.com/docker/machine/blob/master/docs/AVAILABLE_DRIVER_PLUGINS.md) if you want to use something else. 122 | 123 | docker-machine create --driver digitalocean --digitalocean-access-token= octohook 124 | 125 | This creates a digital ocean droplet with 512mb for us, it takes a couple of minutes to run. 126 | 127 | In the meantime, open your `docker-compose.yml`. 128 | 129 | We need to set a secret for octohook to verify incoming webhooks. This is done through environment variables. The name of the environment variable depends on how you called the file you added. If you've added `myrepo.py`, you'll need to set `MYREPO_SECRET`. There's already a environment variable called `SOMEREPO_SECRET` defined. Replace that with yours. 130 | 131 | Once docker-machine is ready, run: 132 | 133 | docker-machine ip octohook 134 | > 123.236.197.123 135 | 136 | and copy the address, we need the it later to create the webhook. 137 | 138 | Now that we have the IP, switch the environment docker-machine is pointing to by running: 139 | 140 | eval $(docker-machine env octohook) 141 | 142 | To push and build the stack on the server, run: 143 | 144 | docker-compose build 145 | 146 | 147 | We are now ready to run the application on the virtual machine, type: 148 | 149 | docker-compose up -d 150 | 151 | to start the server in detached mode. 152 | 153 | To check the logs, run: 154 | 155 | docker-compose logs 156 | 157 | You should get an output similar to this 158 | 159 | hook_1 | [2016-02-19 08:56:12 +0000] [1] [INFO] Starting gunicorn 19.4.5 160 | hook_1 | [2016-02-19 08:56:12 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000 (1) 161 | hook_1 | [2016-02-19 08:56:12 +0000] [1] [INFO] Using worker: sync 162 | hook_1 | [2016-02-19 08:56:12 +0000] [8] [INFO] Booting worker with pid: 8 163 | hook_1 | [2016-02-19 08:56:12 +0000] [9] [INFO] Booting worker with pid: 9 164 | hook_1 | [2016-02-19 08:56:12 +0000] [10] [INFO] Booting worker with pid: 10 165 | hook_1 | [2016-02-19 08:56:13 +0000] [11] [INFO] Booting worker with pid: 11 166 | 167 | If you see an `AssertionError` popping up telling you that you don't have set the secret key, make sure you have set that correctly. Hit `CTRL+C`, change the secret key and run `docker-compose build` and `docker-compose up -d` again to rebuild and restart the server. 168 | 169 | Now, head over to your github repo and click on `Settings` > `Webhooks & services` > `Add webhook` 170 | 171 | - Payload URL is `https:////`, for example `https://1.2.3.4/myrepo/` 172 | - Content type is `application/json` 173 | - Secret is the value of the secret key you set for that repo. 174 | 175 | Make sure to click `Disable SSL verification`. We need to do that because we are using a self signed certificate and Github isn't trusting our CA. That's perfectly fine since we just want that Github sends us all the payload over an encrypted connection. 176 | 177 | Select which events you would like to be send to octohook and click on `Add webhook`. 178 | 179 | Make sure that everything works by triggering an event you selected. Take a look at your logs, you should see an output similar to this: 180 | 181 | nginx_1 | 192.30.252.46 - - [19/Feb/2016:08:45:12 +0000] "POST /myrepo/ HTTP/1.1" 200 2 "-" "GitHub-Hookshot/21f57ba" "-" 182 | 183 | 184 | -------------------------------------------------------------------------------- /dev.yml: -------------------------------------------------------------------------------- 1 | hook: 2 | build: . 3 | ports: 4 | - "5000:5000" 5 | volumes: 6 | - ./hook:/app/hook 7 | - ./repos:/app/repos 8 | environment: 9 | - DEBUG=True 10 | command: ["python", "hook.py"] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | hook: 2 | build: . 3 | environment: 4 | - SOMEREPO_SECRET=SUPER_SECRET_THING 5 | command: "gunicorn -w 4 -b 0.0.0.0:5000 hook:app" 6 | user: "hook" 7 | 8 | nginx: 9 | build: ./dockerfiles/nginx 10 | ports: 11 | - "443:443" 12 | links: 13 | - hook -------------------------------------------------------------------------------- /dockerfiles/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:latest 2 | ADD nginx.conf /etc/nginx/nginx.conf 3 | RUN openssl req -nodes -x509 -newkey rsa:4096 -sha256 -keyout /etc/nginx/key.pem -out /etc/nginx/cert.crt -days 1095 -subj "/C=US/ST=Oregon/L=Portland/O=IT" -------------------------------------------------------------------------------- /dockerfiles/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | 23 | sendfile on; 24 | 25 | keepalive_timeout 65; 26 | 27 | upstream hook { 28 | server hook:5000; 29 | } 30 | 31 | server { 32 | listen 443 ssl; 33 | 34 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 35 | ssl_ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS; 36 | 37 | ssl_prefer_server_ciphers on; 38 | ssl_session_timeout 5m; 39 | ssl_session_cache shared:SSL:50m; 40 | 41 | ssl_certificate /etc/nginx/cert.crt; 42 | ssl_certificate_key /etc/nginx/key.pem; 43 | 44 | charset utf-8; 45 | 46 | location / { 47 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 48 | proxy_set_header Host $http_host; 49 | proxy_redirect off; 50 | proxy_pass http://hook; 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /hook/hook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | import os 4 | import imp 5 | import hmac 6 | import hashlib 7 | import six 8 | 9 | from flask import Flask, abort, request 10 | 11 | DEBUG = os.environ.get("DEBUG", False) == 'True' 12 | HOST = os.environ.get("HOST", '0.0.0.0') 13 | 14 | ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 15 | REPO_DIR = os.path.join(ROOT_DIR, "repos") 16 | GITHUB_EVENTS = [ 17 | "commit_comment", 18 | "create", 19 | "delete", 20 | "deployment", 21 | "deployment_status", 22 | "fork", 23 | "gollum", 24 | "issue_comment", 25 | "issues", 26 | "member", 27 | "membership", 28 | "page_build", 29 | "public", 30 | "pull_request_review_comment", 31 | "pull_request", 32 | "push", 33 | "repository", 34 | "release", 35 | "status", 36 | "team_add", 37 | "watch", 38 | "ping", # sent by github to check if the endpoint is available 39 | ] 40 | 41 | app = Flask(__name__) 42 | 43 | 44 | def hook(repo): 45 | """Processes an incoming webhook, see GITHUB_EVENTS for possible events. 46 | """ 47 | event, signature = ( 48 | request.headers.get('X-Github-Event', False), 49 | request.headers.get('X-Hub-Signature', False) 50 | ) 51 | # If we are not running on DEBUG, the X-Hub-Signature header has to be set. 52 | # Raising a 404 is not the right http return code, but we don't 53 | # want to give someone that is attacking this endpoint a clue 54 | # that we are serving this repo alltogether if he doesn't 55 | # know our secret key 56 | if not DEBUG: 57 | if not signature: 58 | abort(404) 59 | # Check that the payload is signed by the secret key. Again, 60 | # if this is not the case, abort with a 404 61 | if not is_signed(payload=request.get_data(as_text=True), signature=signature, secret=repo.SECRET): 62 | abort(404) 63 | 64 | # make sure the event is set 65 | if event not in GITHUB_EVENTS: 66 | abort(400) 67 | 68 | data = request.get_json() 69 | 70 | # call the always function and the event function (when implemented) 71 | for function in ["always", event]: 72 | if hasattr(repo, function): 73 | getattr(repo, function)(data) 74 | 75 | return "ok" 76 | 77 | 78 | def is_signed(payload, signature, secret): 79 | """ 80 | https://developer.github.com/webhooks/securing/#validating-payloads-from-github 81 | """ 82 | if six.PY3: # pragma: no cover 83 | payload = payload.encode("utf-8") 84 | secret = secret.encode("utf-8") 85 | 86 | digest = "sha1=" + hmac.new( 87 | secret, 88 | msg=payload, 89 | digestmod=hashlib.sha1 90 | ).hexdigest() 91 | return digest == signature 92 | 93 | 94 | def import_repo_by_name(name): 95 | module_name = ".".join(["repos", name]) 96 | full_path = os.path.join(REPO_DIR, name + ".py") 97 | 98 | module = imp.load_source(module_name, full_path) 99 | env_var = "{name}_SECRET".format(name=name.upper()) 100 | if env_var not in os.environ: 101 | if DEBUG: 102 | print("WARNING: You need to set the environment variable {env_var}" 103 | " when not in DEBUG mode.".format( 104 | env_var=env_var 105 | )) 106 | else: 107 | raise AssertionError( 108 | "You need to set {env_var}".format( 109 | env_var=env_var) 110 | ) 111 | else: 112 | setattr(module, "SECRET", os.environ.get(env_var)) 113 | 114 | return module 115 | 116 | 117 | def build_routes(): 118 | for _, _, filenames in os.walk(REPO_DIR): 119 | for filename in filenames: 120 | if filename.endswith(".py"): 121 | name, _, _ = filename.partition(".py") 122 | 123 | app.add_url_rule( 124 | rule="/{}/".format(name), 125 | endpoint=name, 126 | view_func=hook, 127 | methods=["POST"], 128 | defaults={"repo": import_repo_by_name(name)} 129 | ) 130 | 131 | 132 | if __name__ == "__main__": # pragma: no cover 133 | if DEBUG: 134 | print("WARNING: running in DEBUG mode. Incoming webhooks will not be checked for a " 135 | "valid signature.") 136 | build_routes() 137 | app.run(host=HOST, debug=DEBUG) 138 | -------------------------------------------------------------------------------- /hook/hook_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import six 3 | import unittest 4 | from mock import patch, Mock 5 | 6 | os.environ["DEBUG"] = "False" 7 | 8 | import hook 9 | 10 | 11 | class HookTestCase(unittest.TestCase): 12 | 13 | def setUp(self): 14 | self.app = hook.app.test_client() 15 | self.repo = Mock() 16 | self.repo.SECRET = "secret" 17 | hook.app.add_url_rule( 18 | rule="/repo/", 19 | endpoint="repo", 20 | view_func=hook.hook, 21 | methods=["POST"], 22 | defaults={"repo": self.repo} 23 | ) 24 | 25 | def test_repo_does_not_exist(self): 26 | resp = self.app.post("/some-foo/") 27 | self.assertEqual(resp.status_code, 404) 28 | 29 | @patch("hook.DEBUG") 30 | def test_signature_not_required_when_in_debug(self, DEBUG_mocked): 31 | if six.PY2: 32 | DEBUG_mocked.__nonzero__.return_value = True 33 | else: 34 | DEBUG_mocked.__bool__.return_value = True 35 | 36 | resp = self.app.post( 37 | "/repo/", 38 | data=b'{"bogus":"data"}', 39 | headers={"X-Github-Event": "delete"} 40 | ) 41 | self.assertEqual(resp.status_code, 200) 42 | 43 | @patch("hook.import_repo_by_name") 44 | def test_signature_does_not_exist(self, import_repo_by_name_mocked): 45 | import_repo_by_name_mocked.return_value = True 46 | 47 | resp = self.app.post("/repo/") 48 | self.assertEqual(resp.status_code, 404) 49 | 50 | @patch("hook.is_signed") 51 | def test_not_signed(self, is_signed_mocked): 52 | 53 | is_signed_mocked.return_value = False 54 | 55 | resp = self.app.post( 56 | "/repo/", 57 | headers={'X-Hub-Signature': "sig"}, 58 | data="bla" 59 | ) 60 | self.assertEqual(resp.status_code, 404) 61 | is_signed_mocked.assert_called_once_with( 62 | payload="bla", signature="sig", secret="secret" 63 | ) 64 | 65 | @patch("hook.is_signed") 66 | def test_event_not_set(self, is_signed_mocked): 67 | is_signed_mocked.return_value = True 68 | 69 | resp = self.app.post( 70 | "/repo/", 71 | headers={'X-Hub-Signature': "sig"}, 72 | data="bla" 73 | ) 74 | self.assertEqual(resp.status_code, 400) 75 | 76 | @patch("hook.is_signed") 77 | def test_functions_are_called(self, is_signed_mocked): 78 | is_signed_mocked.return_value = True 79 | 80 | resp = self.app.post( 81 | "/repo/", 82 | headers={'X-Hub-Signature': "sig", "X-Github-Event": "delete"}, 83 | data="bla" 84 | ) 85 | #self.repo.always.assert_called_once_with(None) 86 | #self.repo.delete.assert_called_once_with(None) 87 | self.assertEqual(resp.status_code, 200) 88 | 89 | 90 | class IsSignedTestCase(unittest.TestCase): 91 | 92 | def test_signature_does_match(self): 93 | payload = "abdcdefg" 94 | signature = "sha1=01caaa4f0d511f5c24141c4a1b9777c4b79121f0" 95 | secret = "some-secret" 96 | self.assertFalse( 97 | hook.is_signed( 98 | payload=payload, 99 | signature=signature, 100 | secret=secret 101 | ) 102 | ) 103 | 104 | def test_signature_does_not_match(self): 105 | payload = "abdcdefg" 106 | signature = "sha1=6d4d7c822a142b4270cea6b08917d507374834b2" 107 | secret = "some-secret" 108 | self.assertTrue( 109 | hook.is_signed( 110 | payload=payload, 111 | signature=signature, 112 | secret=secret 113 | ) 114 | ) 115 | 116 | 117 | class ImportRepoByNameTestCase(unittest.TestCase): 118 | 119 | @patch("hook.imp.load_source") 120 | def test_secret_is_set(self, load_source): 121 | mocked_repo = Mock() 122 | load_source.return_value = mocked_repo 123 | os.environ["FOO_SECRET"] = "foos-secret" 124 | 125 | repo = hook.import_repo_by_name("foo") 126 | 127 | self.assertEqual(repo.SECRET, "foos-secret") 128 | 129 | @patch("hook.imp.load_source") 130 | def test_secret_not_set(self, load_source): 131 | mocked_repo = Mock() 132 | load_source.return_value = mocked_repo 133 | with self.assertRaises(AssertionError): 134 | repo = hook.import_repo_by_name("repo-without-secret") 135 | 136 | @patch("hook.DEBUG") 137 | @patch("hook.imp.load_source") 138 | def test_secret_not_set_debug(self, load_source, DEBUG_mocked): 139 | mocked_repo = Mock() 140 | load_source.return_value = mocked_repo 141 | 142 | if six.PY2: 143 | DEBUG_mocked.__nonzero__.return_value = True 144 | else: 145 | DEBUG_mocked.__bool__.return_value = True 146 | 147 | repo = hook.import_repo_by_name("repo-without-secret") 148 | 149 | 150 | class BuildRoutesTestCase(unittest.TestCase): 151 | 152 | @patch("hook.import_repo_by_name") 153 | @patch("hook.app") 154 | @patch("hook.os.walk") 155 | def test_build_routes(self, walk_mocked, app_mocked, import_repo_by_name_mocked): 156 | walk_mocked.return_value = [("bar", "baz", ["repo_without_secret.py",])] 157 | import_repo_by_name_mocked.return_value = 'repo-import' 158 | hook.build_routes() 159 | app_mocked.add_url_rule.assert_called_once_with( 160 | defaults={'repo': 'repo-import'}, 161 | endpoint='repo_without_secret', 162 | methods=['POST'], rule='/repo_without_secret/', 163 | view_func=hook.hook 164 | ) 165 | 166 | """ 167 | @patch("hook.os.environ") 168 | @patch("hook.os.walk") 169 | def test_secret_set(self, walk_mocked, environ_mocked): 170 | walk_mocked.return_value = [("bar", "baz", ["repo_with_secret.py",])] 171 | environ_mocked.__contains__.return_value = True 172 | hook.check_environment() 173 | environ_mocked.__contains__.assert_called_once_with('REPO_WITH_SECRET_SECRET')""" 174 | 175 | 176 | if __name__ == '__main__': 177 | unittest.main() 178 | -------------------------------------------------------------------------------- /repos/example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | 4 | 5 | def always(data): 6 | """Any time any event is triggered (Wildcard Event).""" 7 | print(data) 8 | 9 | 10 | def commit_comment(data): 11 | """Any time a Commit is commented on. 12 | See: https://developer.github.com/v3/activity/events/types/#commitcommentevent 13 | """ 14 | print(data) 15 | 16 | 17 | def create(data): 18 | """Any time a Branch or Tag is created. 19 | See: https://developer.github.com/v3/activity/events/types/#createevent 20 | """ 21 | print(data) 22 | 23 | 24 | def delete(data): 25 | """Any time a Branch or Tag is deleted. 26 | See: https://developer.github.com/v3/activity/events/types/#deleteevent 27 | """ 28 | print(data) 29 | 30 | 31 | def deployment(data): 32 | """Any time a Repository has a new deployment created from the API. 33 | See: https://developer.github.com/v3/activity/events/types/#deploymentevent 34 | """ 35 | print(data) 36 | 37 | 38 | def deployment_status(data): 39 | """Any time a deployment for a Repository has a status update from the API. 40 | See: https://developer.github.com/v3/activity/events/types/#deploymentstatusevent 41 | """ 42 | print(data) 43 | 44 | 45 | def fork(data): 46 | """Any time a Repository is forked. 47 | See: https://developer.github.com/v3/activity/events/types/#forkevent 48 | """ 49 | print(data) 50 | 51 | 52 | def gollum(data): 53 | """Any time a Wiki page is updated. 54 | See: https://developer.github.com/v3/activity/events/types/#gollumevent 55 | """ 56 | print(data) 57 | 58 | 59 | def issue_comment(data): 60 | """Any time an Issue or Pull Request is commented on. 61 | See: https://developer.github.com/v3/activity/events/types/#issuecommentevent 62 | """ 63 | print(data) 64 | 65 | 66 | def issues(data): 67 | """Any time an Issue is assigned, unassigned, labeled, unlabeled, opened, closed, or reopened. 68 | See: https://developer.github.com/v3/activity/events/types/#issuesevent 69 | """ 70 | print(data) 71 | 72 | 73 | def member(data): 74 | """Any time a User is added as a collaborator to a non-Organization Repository. 75 | See: https://developer.github.com/v3/activity/events/types/#memberevent 76 | """ 77 | print(data) 78 | 79 | 80 | def membership(data): 81 | """Any time a User is added or removed from a team. Organization hooks only. 82 | See: https://developer.github.com/v3/activity/events/types/#membershipevent 83 | """ 84 | print(data) 85 | 86 | 87 | def page_build(data): 88 | """Any time a Pages site is built or results in a failed build. 89 | See: https://developer.github.com/v3/activity/events/types/#pagebuildevent 90 | """ 91 | print(data) 92 | 93 | 94 | def public(data): 95 | """Any time a Repository changes from private to public. 96 | See: https://developer.github.com/v3/activity/events/types/#publicevent 97 | """ 98 | print(data) 99 | 100 | 101 | def pull_request_review_comment(data): 102 | """Any time a comment is created on a portion of the unified diff of a pull request (the Files 103 | Changed tab). 104 | See: https://developer.github.com/v3/activity/events/types/#pullrequestreviewcommentevent 105 | """ 106 | print(data) 107 | 108 | 109 | def pull_request(data): 110 | """Any time a Pull Request is assigned, unassigned, labeled, unlabeled, opened, closed, 111 | reopened, or synchronized (updated due to a new push in the branch that the pull request is 112 | tracking). 113 | See: https://developer.github.com/v3/activity/events/types/#pullrequestevent 114 | """ 115 | print(data) 116 | 117 | 118 | def push(data): 119 | """Any Git push to a Repository, including editing tags or branches. Commits via API 120 | actions that update references are also counted. This is the default event. 121 | See: https://developer.github.com/v3/activity/events/types/#pushevent 122 | """ 123 | print(data) 124 | 125 | 126 | def repository(data): 127 | """Any time a Repository is created. Organization hooks only. 128 | See: https://developer.github.com/v3/activity/events/types/#repositoryevent 129 | """ 130 | print(data) 131 | 132 | 133 | def release(data): 134 | """Any time a Release is published in a Repository. 135 | See: https://developer.github.com/v3/activity/events/types/#releaseevent 136 | """ 137 | print(data) 138 | 139 | 140 | def status(data): 141 | """Any time a Repository has a status update from the API 142 | See: https://developer.github.com/v3/activity/events/types/#statusevent 143 | """ 144 | print(data) 145 | 146 | 147 | def team_add(data): 148 | """Any time a team is added or modified on a Repository. 149 | See: https://developer.github.com/v3/activity/events/types/#teamaddevent 150 | """ 151 | print(data) 152 | 153 | 154 | def watch(data): 155 | """Any time a User stars a Repository. 156 | See: https://developer.github.com/v3/activity/events/types/#watchevent 157 | """ 158 | print(data) 159 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==0.10.1 2 | six==1.10.0 3 | mock==2.0.0 4 | coverage==4.0.3 5 | gunicorn==19.4.5 -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py33, py34, py35 3 | skipsdist=True 4 | 5 | [testenv] 6 | setenv = 7 | PYTHONPATH = {toxinidir}:{toxinidir}/hook 8 | commands = 9 | coverage run --source=hook hook/hook_test.py 10 | 11 | deps = 12 | -r{toxinidir}/requirements.txt --------------------------------------------------------------------------------