├── .gitignore ├── LICENSE-MIT ├── MANIFEST.in ├── README.rst ├── hooked ├── __init__.py └── server.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | hooked.egg-info 2 | dist 3 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Bruno Binet, 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, 8 | and/or sell copies of the Software, and to permit persons to 9 | whom the Software is furnished to do so, subject to the 10 | following conditions: 11 | 12 | The above copyright notice and this permission notice shall 13 | be included in all copies or substantial portions of the 14 | Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY 17 | KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 18 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 19 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 22 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 23 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE-MIT 2 | include README.rst 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Hooked 2 | ====== 3 | 4 | Run command on GitHub and BitBucket POST request hooks. 5 | 6 | Install 7 | ------- 8 | 9 | You can install `hooked` in a virtualenv (with `virtualenvwrapper` and `pip`):: 10 | 11 | $ mkvirtualenv hooked 12 | (hooked) $ pip install hooked 13 | 14 | Or if you want to contribute some patches to `hooked`:: 15 | 16 | $ git clone git@github.com:bbinet/hooked.git 17 | $ cd hooked/ 18 | $ mkvirtualenv hooked 19 | (hooked) $ python setup.py develop 20 | 21 | Configure 22 | --------- 23 | 24 | Create a configuration file that looks like:: 25 | 26 | $ cat path/to/config.cfg 27 | 28 | [server] 29 | host = 0.0.0.0 30 | port = 8080 31 | server = cherrypy 32 | debug = true 33 | 34 | [hook-myrepo] 35 | repository = myrepo 36 | branch = master 37 | command = /path/to/script.sh 38 | 39 | [hook-all] 40 | #repository = # will match all repository 41 | #branch = # will match all branches 42 | command = /path/to/other/script.sh 43 | 44 | Note that the `[server]` section is optional, the defaults are:: 45 | 46 | [server] 47 | host = localhost 48 | port = 8888 49 | server = wsgiref 50 | debug = false 51 | 52 | Run 53 | --- 54 | 55 | Run the hooked server by running the following command:: 56 | 57 | (hooked) $ hooked path/to/config.cfg 58 | 59 | Then visit http://localhost:8888/, it should return the current configuration 60 | for this `hooked` server. 61 | If this works, you are ready to configure GitHub and BitBucket POST request web 62 | hooks to your `hooked` server listening address, for example: 63 | http://localhost:8888/. 64 | 65 | See: 66 | 67 | - https://confluence.atlassian.com/display/BITBUCKET/POST+hook+management 68 | - https://developer.github.com/webhooks/ 69 | 70 | You can also manually run hooks though GET requests: 71 | 72 | - requesting the /hooks// url will run all hooks that match 73 | repository= and branch= 74 | - requesting the /hook/ url will run the hook which name is 75 | 76 | Release 77 | ------- 78 | 79 | To make a new release, do the following steps:: 80 | 81 | $ vi setup.py # bump version 82 | $ git add setup.py 83 | $ git commit -m "bump version to X.X.X" 84 | $ git tag vX.X.X 85 | $ git push --tags 86 | $ python setup.py sdist upload 87 | 88 | Thanks 89 | ------ 90 | 91 | Thanks to the `hook-server `_ and 92 | `githook `_ projects for inspiration. 93 | -------------------------------------------------------------------------------- /hooked/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbinet/hooked/69906752485ff9d01756c2dd424ba8ccda74391c/hooked/__init__.py -------------------------------------------------------------------------------- /hooked/server.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import logging 4 | import subprocess 5 | import sys 6 | from dataclasses import dataclass 7 | from pprint import pformat 8 | from typing import Any, List 9 | 10 | import bottle 11 | 12 | logging.basicConfig(stream=sys.stdout, level=logging.INFO, 13 | format='%(asctime)-15s %(levelname)s: %(message)s') 14 | log = logging.getLogger(__name__) 15 | config = configparser.ConfigParser() 16 | config['server'] = { 17 | 'host': 'localhost', 18 | 'port': '8888', 19 | 'server': 'wsgiref', 20 | 'debug': 'false', 21 | } 22 | 23 | # read global and custom cfg files 24 | config.read(['/etc/hooked.cfg', './hooked.cfg']) 25 | 26 | 27 | @dataclass 28 | class Hook: 29 | repository: str 30 | branch: str 31 | command: str 32 | 33 | def __init__( 34 | self, 35 | name: str, 36 | repository: str, 37 | branch: str, 38 | command: str, 39 | cwd: str, 40 | *_args: Any, 41 | **_kwargs: Any, 42 | ) -> None: 43 | self.name = name 44 | self.repository = repository 45 | self.branch = branch 46 | self.command = command 47 | self.cwd = cwd 48 | 49 | def __str__(self): 50 | return f'{self.name}@{self.repository} on {self.branch} does {self.command} in {self.cwd}' 51 | 52 | 53 | def config_check() -> List[Hook]: 54 | correct_hooks: List[Hook] = [] 55 | config_hooks = [a for a in set(config.sections()) if a != 'server'] 56 | for hook_data in config_hooks: 57 | try: 58 | # This will test and fail the first missing config option 59 | # TODO: it would be nice to test and report them all 60 | correct_hooks.append( 61 | Hook( 62 | hook_data, 63 | config[hook_data]['repository'], 64 | config[hook_data]['branch'], 65 | config[hook_data]['command'], 66 | config[hook_data]['cwd'], 67 | ) 68 | ) 69 | except KeyError as e: 70 | log.error(f'[{hook_data}] is missing{str(e)}') 71 | except TypeError as e: 72 | msg = str(e).replace('__init__() ', '').replace('positional ', '').replace('argument', 'option') 73 | log.error(f'[{hook_data}] {msg}') 74 | return correct_hooks 75 | 76 | 77 | @bottle.get('/') 78 | def index(): 79 | resp = { 80 | 'success': True, 81 | 'hooks': [], 82 | } 83 | hooks = config_check() 84 | for hook in hooks: 85 | resp['hooks'].append({ 86 | 'name': hook.name, 87 | 'repository': hook.repository, 88 | 'branch': hook.branch, 89 | 'command': hook.command, 90 | 'cwd': hook.cwd, 91 | }) 92 | log.debug(f'GET / response =>\n{pformat(resp)}') 93 | return resp 94 | 95 | 96 | @bottle.get('/hooks//') 97 | def run_hooks(repo, branch): 98 | if not (repo and branch): 99 | return bottle.HTTPError(status=400) 100 | hooks = config_check() 101 | resp = { 102 | 'success': True, 103 | 'hooks': [], 104 | } 105 | for hook in hooks: 106 | if repo != hook.repository: 107 | log.debug(f'"{repo}" repository don\'t match [{hook.name}] hook') 108 | continue 109 | if branch != hook.branch: 110 | log.debug(f'"{branch}" branch don\'t match [{hook.name}] hook') 111 | continue 112 | resp['hooks'].append(run_hook(hook, repo, branch)) 113 | log.debug(resp) 114 | return resp 115 | 116 | 117 | @bottle.post('/') 118 | def run_git_hooks(): 119 | branch = None 120 | repo = None 121 | data = None 122 | if bottle.request.json: 123 | data = bottle.request.json 124 | elif bottle.request.forms.get('payload', None): 125 | data = json.loads(bottle.request.forms.get('payload')) 126 | log.debug(f'POST / request =>\n{pformat(data)}') 127 | 128 | if data: 129 | if 'slug' in data['repository']: 130 | repo = data['repository']['slug'] 131 | elif 'name' in data['repository']: 132 | repo = data['repository']['name'] 133 | if 'ref' in data: 134 | branch = data['ref'].split('/')[-1] 135 | elif 'commits' in data and len(data['commits']) > 0: 136 | branch = data['commits'][0]['branch'] 137 | elif 'push' in data and 'changes' in data['push'] \ 138 | and len(data['push']['changes']) > 0: 139 | branch = data['push']['changes'][0]['new']['name'] 140 | 141 | return run_hooks(repo, branch) 142 | 143 | 144 | @bottle.get('/hook/') 145 | def run_hook(hook, repo='', branch=''): 146 | if not config.has_section(hook.name): 147 | return bottle.HTTPError(status=404) 148 | # optionally get repository/branch from query params 149 | repo = bottle.request.query.get('repository', repo) 150 | branch = bottle.request.query.get('branch', branch) 151 | 152 | out, err = subprocess.Popen( 153 | [hook.command, repo, branch], 154 | cwd=hook.cwd, 155 | stdout=subprocess.PIPE, 156 | stderr=subprocess.PIPE, 157 | ).communicate() 158 | log.info('Running command: {%s}\n' 159 | '--> STDOUT: %s\n' 160 | '--> STDERR: %s' 161 | % (' '.join([hook.command, repo, branch]), 162 | out.decode("utf-8"), 163 | err.decode("utf-8"))) 164 | return { 165 | 'name': hook.name, 166 | 'repository': hook.repository, 167 | 'branch': hook.branch, 168 | 'command': hook.command, 169 | 'cwd': hook.cwd, 170 | 'stdout': out.decode("utf-8"), 171 | 'stderr': err.decode("utf-8"), 172 | } 173 | 174 | 175 | def run(): 176 | if len(sys.argv) > 1: 177 | config.read(sys.argv[1:]) 178 | debug = config.getboolean('server', 'debug') 179 | if debug: 180 | log.setLevel(logging.DEBUG) 181 | bottle.debug(True) 182 | valid_hooks = config_check() 183 | if len(valid_hooks) > 0: 184 | bottle.run( 185 | server=config.get('server', 'server'), 186 | host=config.get('server', 'host'), 187 | port=config.get('server', 'port'), 188 | reloader=debug) 189 | else: 190 | log.error('Config check failed. Exiting ...') 191 | sys.exit(1) 192 | 193 | 194 | if __name__ == '__main__': 195 | config['server']['debug'] = 'true' 196 | run() 197 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | 4 | 5 | setup( 6 | name='hooked', 7 | version='0.4', 8 | description='Run command on GitHub and BitBucket POST request hooks', 9 | long_description=open('README.rst').read(), 10 | license='MIT', 11 | author='Bruno Binet', 12 | author_email='bruno.binet@gmail.com', 13 | keywords=['bitbucket', 'github', 'hook', 'post', 'web', 'webhook'], 14 | url='https://github.com/bbinet/hooked', 15 | classifiers=[ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | 'License :: OSI Approved :: MIT License', 19 | "Operating System :: MacOS :: MacOS X", 20 | "Operating System :: POSIX :: Linux", 21 | "Operating System :: Unix", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3.8", 24 | ], 25 | entry_points={ 26 | 'console_scripts': ['hooked=hooked.server:run'] 27 | }, 28 | packages=find_packages(), 29 | include_package_data=True, 30 | install_requires=[ 31 | 'bottle' 32 | ], 33 | ) 34 | --------------------------------------------------------------------------------